├── .gitattributes
├── .gitignore
├── composer.json
├── phpunit.xml.dist
├── LICENSE.txt
├── .github
└── workflows
│ ├── phpstan.yml
│ └── test.yml
├── README.md
└── src
└── AcceptLanguage.php
/.gitattributes:
--------------------------------------------------------------------------------
1 | /.editorconfig export-ignore
2 | /phpstan.dist.neon export-ignore
3 | /phpunit.xml export-ignore
4 | /tests/* export-ignore
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /build/logs
2 | /build/phpdox
3 | /build/phploc
4 | /build/report
5 | /composer.lock
6 | /phan.log
7 | /phpunit.xml
8 | /vendor/*
9 | /.idea
10 | /*.iml
11 | /.phpunit.result.cache
12 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "zonuexe/http-accept-language",
3 | "description": "HTTP Accept-Language Header parser",
4 | "license": "MIT",
5 | "authors": [
6 | {
7 | "name": "USAMI Kenta",
8 | "email": "tadsan@zonu.me"
9 | }
10 | ],
11 | "require": {
12 | "php": ">=7.2.0",
13 | "ext-intl": "*"
14 | },
15 | "require-dev": {
16 | "phpstan/phpstan": "^1.10",
17 | "phpunit/phpunit": "^8.5|^7.5|^4.8",
18 | "yoast/phpunit-polyfills": "^1.0"
19 | },
20 | "autoload": {
21 | "psr-4": {
22 | "Teto\\HTTP\\": "src/"
23 | }
24 | },
25 | "autoload-dev": {
26 | "psr-4": {
27 | "Teto\\HTTP\\": "tests/"
28 | }
29 | },
30 | "config": {
31 | "sort-packages": true
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
14 |
15 | ./tests/
16 |
17 |
18 |
19 |
20 |
21 | src/
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | HTTP Accept-Language header parser for PHP
2 |
3 | Copyright (c) 2016 Baguette HQ / USAMI Kenta
4 |
5 | MIT License
6 |
7 | Permission is hereby granted, free of charge, to any person obtaining
8 | a copy of this software and associated documentation files (the
9 | "Software"), to deal in the Software without restriction, including
10 | without limitation the rights to use, copy, modify, merge, publish,
11 | distribute, sublicense, and/or sell copies of the Software, and to
12 | permit persons to whom the Software is furnished to do so, subject to
13 | the following conditions:
14 |
15 | The above copyright notice and this permission notice shall be
16 | included in all copies or substantial portions of the Software.
17 |
18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
20 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
22 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
24 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
25 |
--------------------------------------------------------------------------------
/.github/workflows/phpstan.yml:
--------------------------------------------------------------------------------
1 | name: PHPStan
2 | on:
3 | workflow_dispatch:
4 | pull_request:
5 | branches:
6 | - master
7 | paths-ignore:
8 | - '**.md'
9 | push:
10 | branches:
11 | - master
12 | paths-ignore:
13 | - '**.md'
14 | jobs:
15 | run:
16 | name: Run
17 | runs-on: ubuntu-20.04
18 | strategy:
19 | fail-fast: false
20 | env:
21 | key: cache-v1
22 | steps:
23 | - name: Checkout
24 | uses: actions/checkout@v3
25 | - name: Setup PHP with tools
26 | uses: shivammathur/setup-php@v2
27 | with:
28 | php-version: '8.2'
29 | extensions: mbstring, intl, opcache, xdebug, xml
30 | tools: composer, cs2pr
31 | - name: Get Composer cache directory
32 | id: composer-cache-dir
33 | run: |
34 | echo "::set-output name=dir::$(composer config cache-files-dir)"
35 | - name: Restore composer cache
36 | id: composer-cache
37 | uses: actions/cache@v3
38 | with:
39 | path: ${{ steps.composer-cache-dir.outputs.dir }}
40 | key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}
41 | restore-keys: |
42 | ${{ runner.os }}-composer-
43 | - name: Remove composer.lock
44 | run: rm -f composer.lock
45 | - name: Setup Composer
46 | run: composer install
47 | - name: Run PHPStan analysis
48 | run: vendor/bin/phpstan analyse
49 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 | on:
3 | workflow_dispatch:
4 | pull_request:
5 | branches:
6 | - master
7 | paths-ignore:
8 | - '**.md'
9 | push:
10 | branches:
11 | - master
12 | paths-ignore:
13 | - '**.md'
14 | jobs:
15 | run:
16 | name: Run
17 | runs-on: ${{ matrix.operating-system }}
18 | strategy:
19 | fail-fast: false
20 | matrix:
21 | operating-system: [ubuntu-20.04]
22 | php-versions: ['7.2', '7.3', '7.4', '8.0', '8.1', '8.2']
23 | env:
24 | key: cache-v1
25 | steps:
26 | - name: Checkout
27 | uses: actions/checkout@v3
28 | - name: Setup PHP with tools
29 | uses: shivammathur/setup-php@v2
30 | with:
31 | php-version: ${{ matrix.php-versions }}
32 | extensions: mbstring, intl, opcache, xdebug, xml
33 | tools: composer, cs2pr
34 | - name: Get Composer cache directory
35 | id: composer-cache-dir
36 | run: |
37 | echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
38 | - name: Restore composer cache
39 | id: composer-cache
40 | uses: actions/cache@v3
41 | with:
42 | path: ${{ steps.composer-cache-dir.outputs.dir }}
43 | key: ${{ runner.os }}-composer-${{ hashFiles('composer.lock') }}
44 | restore-keys: |
45 | ${{ runner.os }}-composer-
46 | - name: Remove composer.lock
47 | run: rm -f composer.lock
48 | - name: Setup Composer
49 | run: composer install
50 | - name: Run PHPUnit tests
51 | run: vendor/bin/phpunit
52 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | HTTP Accept-Language
2 | ====================
3 |
4 | [](https://packagist.org/packages/zonuexe/http-accept-language)
5 | [](https://github.com/BaguettePHP/http-accept-language/actions)
6 | [](https://packagist.org/packages/zonuexe/http-accept-language)
7 |
8 | `Teto\HTTP\AcceptLanguage` is HTTP `Accept-Language` header parser based on PHP [`Locale`][Locale] module.
9 |
10 | [Locale]: https://www.php.net/Locale
11 |
12 | ## Future scope
13 |
14 | This package was designed ten years ago and is considered legacy due to its global dependencies. Over time I will provide a new package as part of the [Hakone] project.
15 |
16 | [Hakone]: https://github.com/hakonephp
17 |
18 | Requirements
19 | ------------
20 |
21 | * PHP (7.2+)
22 | * `ext/intl`
23 |
24 | Installation
25 | ------------
26 |
27 | ```
28 | composer require zonuexe/http-accept-language
29 | ```
30 |
31 | Usage
32 | -----
33 |
34 | see `tests/public/greeting.php`.
35 |
36 | API
37 | ---
38 |
39 | * `Teto\HTTP\AcceptLanguage::detect()`
40 | * `Teto\HTTP\AcceptLanguage::get()`
41 | * `Teto\HTTP\AcceptLanguage::getLanguages()`
42 | * `Teto\HTTP\AcceptLanguage::parse()`
43 |
44 | Features
45 | --------
46 |
47 | * Accepts `*`(wildcard) tag
48 | * `*-Hant-*` → `{language: '*', script: 'Hant'}`
49 | * `zh-*-TW` → `{language: 'zh', region: 'TW'}`
50 |
51 | Reference
52 | ---------
53 |
54 | * [RFC 9110 - HTTP Semantics #12.5.4. Accept-Language][rfc9110-accept-language]
55 | * [RFC 4647 - Matching of Language Tags][rfc4647]
56 | * [RFC 5646 - Tags for Identifying Languages][rfc5646]
57 |
58 | [rfc9110-accept-language]: https://www.rfc-editor.org/rfc/rfc9110.html#name-accept-language
59 | [rfc4647]: https://datatracker.ietf.org/doc/html/rfc4647
60 | [rfc5646]: https://datatracker.ietf.org/doc/html/rfc5646
61 |
62 | Copyright
63 | ---------
64 |
65 | > **HTTP Accept-Language header parser for PHP**
66 | >
67 | > Copyright (c) 2016 Baguette HQ / USAMI Kenta
68 | >
69 | > MIT License
70 | >
71 | > Permission is hereby granted, free of charge, to any person obtaining
72 | > a copy of this software and associated documentation files (the
73 | > "Software"), to deal in the Software without restriction, including
74 | > without limitation the rights to use, copy, modify, merge, publish,
75 | > distribute, sublicense, and/or sell copies of the Software, and to
76 | > permit persons to whom the Software is furnished to do so, subject to
77 | > the following conditions:
78 | >
79 | > The above copyright notice and this permission notice shall be
80 | > included in all copies or substantial portions of the Software.
81 | >
82 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
83 | > EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
84 | > MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
85 | > NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
86 | > LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
87 | > OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
88 | > WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
89 |
90 |
--------------------------------------------------------------------------------
/src/AcceptLanguage.php:
--------------------------------------------------------------------------------
1 |
22 | * @copyright 2016 Baguette HQ
23 | * @license MIT License
24 | * @phpstan-type accept_language_parsed array{
25 | * language: string,
26 | * script: string,
27 | * region: string,
28 | * variant1: string,
29 | * variant2: string,
30 | * variant3: string,
31 | * private1: string,
32 | * private2: string,
33 | * private3: string
34 | * }
35 | * @phpstan-type accept_language_sparse array{
36 | * language: string,
37 | * script?: string,
38 | * region?: string,
39 | * variant1?: string,
40 | * variant2?: string,
41 | * variant3?: string,
42 | * private1?: string,
43 | * private2?: string,
44 | * private3?: string
45 | * }
46 | */
47 | class AcceptLanguage
48 | {
49 | /**
50 | * @param string $http_accept_language
51 | * @phpstan-return list
52 | */
53 | public static function get($http_accept_language = '')
54 | {
55 | if (!$http_accept_language) {
56 | $http_accept_language = isset($_SERVER['HTTP_ACCEPT_LANGUAGE']) ? $_SERVER['HTTP_ACCEPT_LANGUAGE'] : '';
57 | }
58 |
59 | $languages = [];
60 | foreach (self::getLanguages($http_accept_language) as $quality_group) {
61 | foreach ($quality_group as $lang) {
62 | $languages[] = $lang;
63 | }
64 | }
65 |
66 | return $languages;
67 | }
68 |
69 | /**
70 | * @template TReturn
71 | * @template TDefault
72 | * @param callable(array): TReturn $strategy
73 | * @param string $http_accept_language
74 | * @phpstan-param TDefault $default
75 | * @phpstan-return TReturn|TDefault~null
76 | */
77 | public static function detect(callable $strategy, $default, $http_accept_language = '')
78 | {
79 | if (!$http_accept_language) {
80 | $http_accept_language = isset($_SERVER['HTTP_ACCEPT_LANGUAGE']) ? $_SERVER['HTTP_ACCEPT_LANGUAGE'] : '';
81 | }
82 |
83 | foreach (self::get($http_accept_language) as $lang) {
84 | $result = $strategy($lang);
85 | if (!empty($result)) {
86 | return $result;
87 | }
88 | }
89 |
90 | return $default;
91 | }
92 |
93 | /**
94 | * @param non-empty-string $http_accept_language
95 | * @param positive-int $resolution Resolution of `q`(quality) value
96 | * @return array>
97 | */
98 | public static function getLanguages($http_accept_language, $resolution = 100)
99 | {
100 | $tags = array_filter(array_map(self::class . '::parse', explode(',', $http_accept_language)));
101 |
102 | $grouped_tags = [];
103 | foreach ($tags as [$q, $tag]) {
104 | $intq = (int)round($q * $resolution, 0, PHP_ROUND_HALF_UP);
105 | if (isset($grouped_tags[$intq])) {
106 | $grouped_tags[$intq][] = $tag;
107 | } else {
108 | $grouped_tags[$intq] = [$tag];
109 | }
110 | }
111 | krsort($grouped_tags, SORT_NUMERIC);
112 |
113 | return $grouped_tags;
114 | }
115 |
116 | /**
117 | * @param string $locale_str LanguageTag (with quality)
118 | * @link http://php.net/manual/locale.parselocale.php
119 | * @return array 2-tuple(float:quality, array:locale)
120 | * @phpstan-return array{}|array{float, accept_language_parsed}
121 | */
122 | public static function parse($locale_str)
123 | {
124 | $split = array_map('trim', explode(';', $locale_str, 2));
125 | if (!isset($split[0]) || strlen($split[0]) === 0) {
126 | return [];
127 | }
128 |
129 | if (strpos($split[0], '*') === 0) {
130 | $lang_tag = str_replace('*', 'xx', $split[0]);
131 | $is_wildcard = true;
132 | } else {
133 | $lang_tag = $split[0];
134 | $is_wildcard = false;
135 | }
136 |
137 | $lang_tag = str_replace('-*', '', $lang_tag);
138 |
139 | if (isset($split[1]) && strpos($split[1], 'q=') === 0) {
140 | $q = (float)substr($split[1], 2);
141 |
142 | if (!is_numeric($q) || $q <= 0 || 1 < $q) {
143 | return [];
144 | }
145 | } else {
146 | $q = 1.0;
147 | }
148 |
149 | /** @phpstan-var accept_language_sparse */
150 | $locale = \Locale::parseLocale($lang_tag);
151 |
152 | if ($is_wildcard) {
153 | $locale['language'] = '*';
154 | }
155 |
156 | return array($q, self::fillLocaleArrayKey($locale));
157 | }
158 |
159 | /**
160 | * @phpstan-param accept_language_sparse $locale
161 | * @phpstan-return accept_language_parsed
162 | * @link http://php.net/manual/locale.composelocale.php
163 | */
164 | private static function fillLocaleArrayKey(array $locale): array
165 | {
166 | return $locale + [
167 | 'language' => '',
168 | 'script' => '',
169 | 'region' => '',
170 | 'variant1' => '',
171 | 'variant2' => '',
172 | 'variant3' => '',
173 | 'private1' => '',
174 | 'private2' => '',
175 | 'private3' => '',
176 | ];
177 | }
178 | }
179 |
--------------------------------------------------------------------------------