├── .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 | [![Package version](http://img.shields.io/packagist/v/zonuexe/http-accept-language.svg?style=flat)](https://packagist.org/packages/zonuexe/http-accept-language) 5 | [![Build Status](https://github.com/BaguettePHP/http-accept-language/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/BaguettePHP/http-accept-language/actions) 6 | [![Downloads this Month](https://img.shields.io/packagist/dm/zonuexe/http-accept-language.svg)](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 | --------------------------------------------------------------------------------