URLify.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. <?php
  2. /**
  3. * A fast PHP slug generator and transliteration library, started as a PHP port of URLify.js
  4. * from the Django project + fallback via "Portable ASCII".
  5. *
  6. * - https://github.com/django/django/blob/master/django/contrib/admin/static/admin/js/urlify.js
  7. * - https://github.com/voku/portable-ascii
  8. *
  9. * Handles symbols from latin languages, Arabic, Azerbaijani, Bulgarian, Burmese, Croatian, Czech, Danish, Esperanto,
  10. * Estonian, Finnish, French, Switzerland (French), Austrian (French), Georgian, German, Switzerland (German),
  11. * Austrian (German), Greek, Hindi, Kazakh, Latvian, Lithuanian, Norwegian, Persian, Polish, Romanian, Russian, Swedish,
  12. * Serbian, Slovak, Turkish, Ukrainian and Vietnamese ... and many other via "ASCII::to_transliterate()".
  13. */
  14. class URLify
  15. {
  16. /**
  17. * The language-mapping array.
  18. *
  19. * ISO 639-1 codes: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
  20. *
  21. * @var array[]
  22. */
  23. public static $maps = [];
  24. /**
  25. * List of words to remove from URLs.
  26. *
  27. * @var array[]
  28. */
  29. public static $remove_list = [];
  30. /**
  31. * An array of strings that will convert into the separator-char - used by "URLify::filter()".
  32. *
  33. * @var string[]
  34. */
  35. private static $arrayToSeparator = [];
  36. /**
  37. * Add new strings the will be replaced with the separator.
  38. *
  39. * @param array $array <p>An array of things that should replaced by the separator.</p>
  40. * @param bool $merge <p>Keep the previous (default) array-to-separator array.</p>
  41. *
  42. * @return void
  43. *
  44. * @psalm-param string[] $array
  45. */
  46. public static function add_array_to_separator(array $array, bool $merge = true)
  47. {
  48. if ($merge === true) {
  49. self::$arrayToSeparator = \array_unique(
  50. \array_merge(
  51. self::$arrayToSeparator,
  52. $array
  53. )
  54. );
  55. } else {
  56. self::$arrayToSeparator = $array;
  57. }
  58. }
  59. /**
  60. * Add new characters to the list. `$map` should be a hash.
  61. *
  62. * @param array $map
  63. * @param string|null $language
  64. *
  65. * @return void
  66. *
  67. * @psalm-param array<string, string> $map
  68. */
  69. public static function add_chars(array $map, ?string $language = null)
  70. {
  71. $language_key = $language ?? \uniqid('urlify', true);
  72. if (isset(self::$maps[$language_key])) {
  73. self::$maps[$language_key] = \array_merge($map, self::$maps[$language_key]);
  74. } else {
  75. self::$maps[$language_key] = $map;
  76. }
  77. }
  78. /**
  79. * @return void
  80. */
  81. public static function reset_chars()
  82. {
  83. self::$maps = [];
  84. }
  85. /**
  86. * Transliterates characters to their ASCII equivalents.
  87. * $language specifies a priority for a specific language.
  88. * The latter is useful if languages have different rules for the same character.
  89. *
  90. * @param string $string <p>The input string.</p>
  91. * @param string $language <p>Your primary language.</p>
  92. * @param string $unknown <p>Character use if character unknown. (default is ?).</p>
  93. *
  94. * @return string
  95. */
  96. public static function downcode(
  97. string $string,
  98. string $language = 'en',
  99. string $unknown = ''
  100. ): string {
  101. $string = self::expandString($string, $language);
  102. foreach (self::$maps as $mapsInner) {
  103. foreach ($mapsInner as $orig => $replace) {
  104. $string = \str_replace($orig, $replace, $string);
  105. }
  106. }
  107. $string = \voku\helper\ASCII::to_ascii(
  108. $string,
  109. $language,
  110. false,
  111. true
  112. );
  113. return \voku\helper\ASCII::to_transliterate(
  114. $string,
  115. $unknown,
  116. false
  117. );
  118. }
  119. /**
  120. * Convert a String to URL slug. Wraps <strong>filter()</strong> with a simpler
  121. * set of defaults for typical usage in generating blog post slugs.
  122. *
  123. * @param string $string <p>The text you want to convert.</p>
  124. * @param int $maxLength <p>Max. length of the output string, set to "0" (zero) to
  125. * disable it</p>
  126. * @param string $separator <p>Define a new separator for the words.</p>
  127. * @param string $language <p>The language you want to convert to.</p>
  128. */
  129. public static function slug(
  130. string $string,
  131. int $maxLength = 200,
  132. string $separator = '-',
  133. string $language = 'en'
  134. ): string {
  135. return self::filter ($string, $maxLength, $language, false, false, true, $separator);
  136. }
  137. /**
  138. * Convert a String to URL.
  139. *
  140. * e.g.: "Petty<br>theft" to "Petty-theft"
  141. *
  142. * @param string $string <p>The text you want to convert.</p>
  143. * @param int $maxLength <p>Max. length of the output string, set to "0" (zero) to
  144. * disable it</p>
  145. * @param string $language <p>The language you want to convert to.</p>
  146. * @param bool $fileName <p>
  147. * Keep the "." from the extension e.g.: "imaäe.jpg" =>
  148. * "image.jpg"
  149. * </p>
  150. * @param bool $removeWords <p>
  151. * Remove some "words" from the string.<br />
  152. * Info: Set extra words via <strong>remove_words()</strong>.
  153. * </p>
  154. * @param bool $strToLower <p>Use <strong>strtolower()</strong> at the end.</p>
  155. * @param bool|string $separator <p>Define a new separator for the words.</p>
  156. *
  157. * @return string
  158. */
  159. public static function filter(
  160. string $string,
  161. int $maxLength = 200,
  162. string $language = 'en',
  163. bool $fileName = false,
  164. bool $removeWords = false,
  165. bool $strToLower = true,
  166. $separator = '-'
  167. ): string {
  168. if ($string === '') {
  169. return '';
  170. }
  171. // fallback
  172. if ($language === '') {
  173. $language = 'en';
  174. }
  175. // separator-fallback
  176. if ($separator === false) {
  177. $separator = '_';
  178. }
  179. if ($separator === true || $separator === '') {
  180. $separator = '-';
  181. }
  182. // escaped separator
  183. $separatorEscaped = \preg_quote($separator, '/');
  184. // use defaults, if there are no values
  185. if (self::$arrayToSeparator === []) {
  186. self::reset_array_to_separator();
  187. }
  188. // remove apostrophes which are not used as quotes around a string
  189. if (\strpos($string, "'") !== false) {
  190. $stringTmp = \preg_replace("/(\w)'(\w)/u", '${1}${2}', $string);
  191. if ($stringTmp !== null) {
  192. $string = (string) $stringTmp;
  193. }
  194. }
  195. // replace with $separator
  196. $string = (string) \preg_replace(
  197. self::$arrayToSeparator,
  198. $separator,
  199. $string
  200. );
  201. // remove all other html-tags
  202. if (
  203. \strpos($string, '<') !== false
  204. ||
  205. \strpos($string, '>') !== false
  206. ) {
  207. $string = \strip_tags($string);
  208. }
  209. // use special language replacer
  210. $string = self::downcode($string, $language);
  211. // replace with $separator, again
  212. $string = (string) \preg_replace(
  213. self::$arrayToSeparator,
  214. $separator,
  215. $string
  216. );
  217. // remove all these words from the string before urlifying
  218. $removeWordsSearch = '//';
  219. if ($removeWords === true) {
  220. $removeList = self::get_remove_list($language);
  221. if ($removeList !== []) {
  222. $removeWordsSearch = '/\b(?:' . \implode('|', $removeList) . ')\b/ui';
  223. }
  224. }
  225. // keep the "." from e.g.: a file-extension?
  226. if ($fileName) {
  227. $removePatternAddOn = '.';
  228. } else {
  229. $removePatternAddOn = '';
  230. }
  231. $string = (string) \preg_replace(
  232. [
  233. // 1) remove un-needed chars
  234. '/[^' . $separatorEscaped . $removePatternAddOn . '\-a-zA-Z0-9\s]/u',
  235. // 2) convert spaces to $separator
  236. '/[\s]+/u',
  237. // 3) remove some extras words
  238. $removeWordsSearch,
  239. // 4) remove double $separator's
  240. '/[' . ($separatorEscaped ?: ' ') . ']+/u',
  241. // 5) remove $separator at the end
  242. '/[' . ($separatorEscaped ?: ' ') . ']+$/u',
  243. ],
  244. [
  245. '',
  246. $separator,
  247. '',
  248. $separator,
  249. '',
  250. ],
  251. $string
  252. );
  253. // "substr" only if "$length" is set
  254. if (
  255. $maxLength
  256. &&
  257. $maxLength > 0
  258. &&
  259. \strlen($string) > $maxLength
  260. ) {
  261. $string = (string) \substr(\trim($string, $separator), 0, $maxLength);
  262. }
  263. // convert to lowercase
  264. if ($strToLower === true) {
  265. $string = \strtolower($string);
  266. }
  267. // trim "$separator" from beginning and end of the string
  268. return \trim($string, $separator);
  269. }
  270. /**
  271. * Append words to the remove list. Accepts either single words or an array of words.
  272. *
  273. * @param string|string[] $words
  274. * @param string $language
  275. * @param bool $merge <p>Keep the previous (default) remove-words array.</p>
  276. *
  277. * @return void
  278. */
  279. public static function remove_words($words, string $language = 'en', bool $merge = true)
  280. {
  281. if (\is_array($words) === false) {
  282. $words = [$words];
  283. }
  284. foreach ($words as $removeWordKey => $removeWord) {
  285. $words[$removeWordKey] = \preg_quote($removeWord, '/');
  286. }
  287. if ($merge === true) {
  288. self::$remove_list[$language] = \array_unique(
  289. \array_merge(
  290. self::get_remove_list($language),
  291. $words
  292. )
  293. );
  294. } else {
  295. self::$remove_list[$language] = $words;
  296. }
  297. }
  298. /**
  299. * Reset the internal "self::$arrayToSeparator" to the default values.
  300. *
  301. * @return void
  302. */
  303. public static function reset_array_to_separator()
  304. {
  305. self::$arrayToSeparator = [
  306. '/&quot;|&amp;|&lt;|&gt;|&ndash;|&mdash;/i', // ", &, <, >, –, —
  307. '/⁻|-|—|_|"|`|´|\'/',
  308. "#/\r\n|\r|\n|<br.*/?>#isU",
  309. ];
  310. }
  311. /**
  312. * reset the word-remove-array
  313. *
  314. * @param string $language
  315. *
  316. * @return void
  317. */
  318. public static function reset_remove_list(string $language = 'en')
  319. {
  320. if ($language === '') {
  321. return;
  322. }
  323. $language_orig = $language;
  324. $language = self::get_language_for_reset_remove_list($language);
  325. if ($language === '') {
  326. return;
  327. }
  328. $stopWords = new \voku\helper\StopWords();
  329. try {
  330. self::$remove_list[$language_orig] = $stopWords->getStopWordsFromLanguage($language);
  331. } catch (\voku\helper\StopWordsLanguageNotExists $e) {
  332. self::$remove_list[$language_orig] = [];
  333. }
  334. }
  335. /**
  336. * Alias of `URLify::downcode()`.
  337. *
  338. * @param string $string
  339. * @param string $language
  340. *
  341. * @return string
  342. */
  343. public static function transliterate(string $string, string $language = 'en'): string
  344. {
  345. return self::downcode($string, $language);
  346. }
  347. /**
  348. * Expands the given string replacing some special parts for words.
  349. * e.g. "lorem@ipsum.com" is replaced by "lorem at ipsum dot com".
  350. *
  351. * Most of these transformations have been inspired by the pelle/slugger
  352. * project, distributed under the Eclipse Public License.
  353. * Copyright 2012 Pelle Braendgaard
  354. *
  355. * @param string $string The string to expand
  356. * @param string $language
  357. *
  358. * @return string The result of expanding the string
  359. */
  360. protected static function expandString(string $string, string $language = 'en'): string
  361. {
  362. $string = self::expandCurrencies($string, $language);
  363. return self::expandSymbols($string, $language);
  364. }
  365. /**
  366. * @param string $language
  367. *
  368. * @return string
  369. */
  370. private static function get_language_for_reset_remove_list(string $language)
  371. {
  372. if ($language === '') {
  373. return '';
  374. }
  375. if (
  376. \strpos($language, '_') === false
  377. &&
  378. \strpos($language, '-') === false
  379. ) {
  380. $language = \strtolower($language);
  381. } else {
  382. $regex = '/(?<first>[a-z]{2}).*/i';
  383. $language = \strtolower((string) \preg_replace($regex, '$1', $language));
  384. }
  385. return $language;
  386. }
  387. /**
  388. * Expands the numeric currencies in euros, dollars, pounds
  389. * and yens that the given string may include.
  390. *
  391. * @param string $string
  392. * @param string $language
  393. *
  394. * @return string
  395. */
  396. private static function expandCurrencies(string $string, string $language = 'en')
  397. {
  398. if (
  399. \strpos($string, '€') === false
  400. &&
  401. \strpos($string, '$') === false
  402. &&
  403. \strpos($string, '£') === false
  404. &&
  405. \strpos($string, '¥') === false
  406. ) {
  407. return $string;
  408. }
  409. if ($language === 'de') {
  410. return (string) \preg_replace(
  411. [
  412. '/(?:\s|^)(\d+)(?: )*€(?:\s|$)/',
  413. '/(?:\s|^)\$(?: )*(\d+)(?:\s|$)/',
  414. '/(?:\s|^)£(?: )*(\d+)(?:\s|$)/',
  415. '/(?:\s|^)¥(?: )*(\d+)(?:\s|$)/',
  416. '/(?:\s|^)(\d+)[.|,](\d+)(?: )*€(?:\s|$)/',
  417. '/(?:\s|^)\$(?: )*(\d+)[.|,](\d+)(?:\s|$)/',
  418. '/(?:\s|^)£(?: )*(\d+)[.|,](\d+)(?:\s|$)/',
  419. ],
  420. [
  421. ' \1 Euro ',
  422. ' \1 Dollar ',
  423. ' \1 Pound ',
  424. ' \1 Yen ',
  425. ' \1 Euro \2 Cent ',
  426. ' \1 Dollar \2 Cent ',
  427. ' \1 Pound \2 Pence ',
  428. ],
  429. $string
  430. );
  431. }
  432. return (string) \preg_replace(
  433. [
  434. '/(?:\s|^)1(?: )*€(?:\s|$)/',
  435. '/(?:\s|^)(\d+)(?: )*€(?:\s|$)/',
  436. '/(?:\s|^)\$(?: )*1(?:\s|$)/',
  437. '/(?:\s|^)\$(?: )*(\d+)(?:\s|$)/',
  438. '/(?:\s|^)£(?: )*1(?:\s|$)/',
  439. '/(?:\s|^)£(?: )*(\d+)(?:\s|$)/',
  440. '/(?:\s|^)¥(?: )*(\d+)(?:\s|$)/',
  441. '/(?:\s|^)1[.|,](\d+)(?: )*€(?:\s|$)/',
  442. '/(?:\s|^)(\d+)[.|,](\d+)(?: )*€(?:\s|$)/',
  443. '/(?:\s|^)1[.|,](\d+)(?: )*$(?:\s|$)/',
  444. '/(?:\s|^)\$(?: )*(\d+)[.|,](\d+)(?:\s|$)/',
  445. '/(?:\s|^)1[.|,](\d+)(?: )*£(?:\s|$)/',
  446. '/(?:\s|^)£(?: )*(\d+)[.|,](\d+)(?:\s|$)/',
  447. ],
  448. [
  449. ' 1 Euro ',
  450. ' \1 Euros ',
  451. ' 1 Dollar ',
  452. ' \1 Dollars ',
  453. ' 1 Pound ',
  454. ' \1 Pounds ',
  455. ' \1 Yen ',
  456. ' 1 Euros \1 Cents ',
  457. ' \1 Euros \2 Cents ',
  458. ' 1 Dollars \1 Cents ',
  459. ' \1 Dollars \2 Cents ',
  460. ' 1 Pounds \1 Pence ',
  461. ' \1 Pounds \2 Pence ',
  462. ],
  463. $string
  464. );
  465. }
  466. /**
  467. * Expands the special symbols that the given string may include, such as '@', '.', '#' and '%'.
  468. *
  469. * @param string $string
  470. * @param string $language
  471. *
  472. * @return string
  473. */
  474. private static function expandSymbols(string $string, string $language = 'en')
  475. {
  476. if (
  477. \strpos($string, '©') === false
  478. &&
  479. \strpos($string, '®') === false
  480. &&
  481. \strpos($string, '@') === false
  482. &&
  483. \strpos($string, '&') === false
  484. &&
  485. \strpos($string, '%') === false
  486. &&
  487. \strpos($string, '=') === false
  488. ) {
  489. return $string;
  490. }
  491. $maps = \voku\helper\ASCII::charsArray(true);
  492. return (string) \preg_replace(
  493. [
  494. '/\s*©\s*/',
  495. '/\s*®\s*/',
  496. '/\s*@\s*/',
  497. '/\s*&\s*/',
  498. '/\s*%\s*/',
  499. '/(\s*=\s*)/',
  500. ],
  501. [
  502. $maps['latin_symbols']['©'],
  503. $maps['latin_symbols']['®'],
  504. $maps['latin_symbols']['@'],
  505. $maps[$language]['&'] ?? '&',
  506. $maps[$language]['%'] ?? '%',
  507. $maps[$language]['='] ?? '=',
  508. ],
  509. $string
  510. );
  511. }
  512. /**
  513. * return the "self::$remove_list[$language]" array
  514. *
  515. * @param string $language
  516. *
  517. * @return array<mixed>
  518. */
  519. private static function get_remove_list(string $language = 'en')
  520. {
  521. // check for language
  522. if ($language === '') {
  523. return [];
  524. }
  525. // set remove-array
  526. if (!isset(self::$remove_list[$language])) {
  527. self::reset_remove_list($language);
  528. }
  529. // check for array
  530. if (
  531. !isset(self::$remove_list[$language])
  532. ||
  533. empty(self::$remove_list[$language])
  534. ) {
  535. return [];
  536. }
  537. return self::$remove_list[$language];
  538. }
  539. }