1: <?php
2:
3: namespace Xoops\Core\Text;
4:
5: /**
6: * WordPress style ShortCodes
7: * This is taken from https://github.com/Badcow/Shortcodes where it was described as:
8: * > This is a port of WordPress' brilliant shortcode feature for
9: * > use outside of WordPress. The code has remained largely unchanged
10: *
11: * WordPress, source of the original code, wp-includes/shortcodes.php, is licensed under the GPL
12: *
13: * @category Sanitizer\ShortCodes
14: * @package Xoops\Core\Text
15: * @author Sam Williams <sam@swilliams.com.au>
16: * @license GNU GPL 2 (http://www.gnu.org/licenses/gpl-2.0.html)
17: * @link https://github.com/Badcow/Shortcodes
18: * @link https://github.com/WordPress/WordPress/blob/master/wp-includes/shortcodes.php
19: */
20: class ShortCodes
21: {
22: /**
23: * The regex for attributes.
24: *
25: * This regex covers the following attribute situations:
26: * - key = "value"
27: * - key = 'value'
28: * - key = value
29: * - "value"
30: * - value
31: *
32: * @var string
33: */
34: private $attrPattern = '/(\w+)\s*=\s*"([^"]*)"(?:\s|$)|(\w+)\s*=\s*\'([^\']*)\'(?:\s|$)|(\w+)\s*=\s*([^\s\'"]+)(?:\s|$)|"([^"]*)"(?:\s|$)|(\S+)(?:\s|$)/';
35:
36: /**
37: * Indexed array of tags: shortcode callbacks
38: *
39: * @var array
40: */
41: private $shortcodes = array();
42:
43: /**
44: * add a shortcode to the active set
45: *
46: * @param string $tag shortcode name
47: * @param callable $function shortcode processor
48: *
49: * @return void
50: *
51: * @throws \ErrorException
52: */
53: public function addShortcode($tag, $function)
54: {
55: if (!is_callable($function)) {
56: throw new \ErrorException("Function must be callable");
57: }
58:
59: $this->shortcodes[$tag] = $function;
60: }
61:
62: /**
63: * remove shortcode from the active set
64: *
65: * @param string $tag short code tag
66: *
67: * @return void
68: */
69: public function removeShortcode($tag)
70: {
71: if (array_key_exists($tag, $this->shortcodes)) {
72: unset($this->shortcodes[$tag]);
73: }
74: }
75:
76: /**
77: * get the current shortcode set
78: *
79: * @return array of tag => callable
80: */
81: public function getShortcodes()
82: {
83: return $this->shortcodes;
84: }
85:
86: /**
87: * Check if a shortcode is defined
88: *
89: * @param string $shortcode shortcode tag
90: *
91: * @return bool true is shortcode is defined in the active set
92: */
93: public function hasShortcode($shortcode)
94: {
95: return array_key_exists($shortcode, $this->shortcodes);
96: }
97:
98: /**
99: * Tests whether content has a particular shortcode
100: *
101: * @param string $content content to check
102: * @param string $tag tag to look for
103: *
104: * @return bool true if tag is used in content, otherwise false
105: */
106: public function contentHasShortcode($content, $tag)
107: {
108: if (!$this->hasShortcode($tag)) {
109: return false;
110: }
111:
112: preg_match_all($this->shortcodeRegex(), $content, $matches, PREG_SET_ORDER);
113:
114: if (empty($matches)) {
115: return false;
116: }
117:
118: foreach ($matches as $shortcode) {
119: if ($tag === $shortcode[2]) {
120: return true;
121: }
122: }
123:
124: return false;
125: }
126:
127: /**
128: * Search content for shortcodes and filter shortcodes through their hooks.
129: *
130: * If there are no shortcode tags defined, then the content will be returned
131: * without any filtering. This might cause issues when plugins are disabled but
132: * the shortcode will still show up in the post or content.
133: *
134: * @param string $content Content to search for shortcodes
135: *
136: * @return string Content with shortcodes filtered out.
137: */
138: public function process($content)
139: {
140: if (empty($this->shortcodes)) {
141: return $content;
142: }
143:
144: return preg_replace_callback($this->shortcodeRegex(), array($this, 'processTag'), $content);
145: }
146:
147: /**
148: * Remove all shortcode tags from the given content.
149: *
150: * @param string $content Content to remove shortcode tags.
151: *
152: * @return string Content without shortcode tags.
153: */
154: public function stripAllShortcodes($content)
155: {
156: if (empty($this->shortcodes)) {
157: return $content;
158: }
159:
160: return preg_replace_callback($this->shortcodeRegex(), array($this, 'stripShortcodeTag'), $content);
161: }
162:
163: /**
164: * Regular Expression callable for do_shortcode() for calling shortcode hook.
165: *
166: * @param array $tag Regular expression match array
167: *
168: * @return mixed False on failure.
169: *
170: * @see get_shortcode_regex for details of the match array contents.
171: */
172: private function processTag(array $tag)
173: {
174: // allow [[foo]] syntax for escaping a tag
175: if ($tag[1] === '[' && $tag[6] === ']') {
176: //return substr($tag[0], 1, -1);
177: return '[' . substr($tag[0], 2, -2) . ']';
178: }
179:
180: $tagName = $tag[2];
181: $attr = $this->parseAttributes($tag[3]);
182:
183: if (isset($tag[5])) {
184: // enclosing tag - extra parameter
185: return $tag[1] . call_user_func($this->shortcodes[$tagName], $attr, $tag[5], $tagName) . $tag[6];
186: } else {
187: // self-closing tag
188: return $tag[1] . call_user_func($this->shortcodes[$tagName], $attr, null, $tagName) . $tag[6];
189: }
190: }
191:
192: /**
193: * Combine user attributes with known attributes and fill in defaults when needed.
194: *
195: * The $defaults should be considered to be all of the attributes which are
196: * supported by the caller and given as a list. The returned attributes will
197: * only contain the attributes in the $defaults list.
198: *
199: * If the $attributes list has unsupported attributes, then they will be ignored and
200: * removed from the final returned list.
201: *
202: * @param array $defaults Entire list of supported attributes and their defaults.
203: * @param array $attributes User defined attributes in shortcode tag.
204: *
205: * @return array Combined and filtered attribute list.
206: */
207: public function shortcodeAttributes($defaults, $attributes)
208: {
209: $attributes = (array)$attributes;
210: $out = array();
211: foreach ($defaults as $name => $default) {
212: if (array_key_exists($name, $attributes)) {
213: $out[$name] = $attributes[$name];
214: } else {
215: $out[$name] = $default;
216: }
217: }
218:
219: return $out;
220: }
221:
222:
223: /**
224: * Retrieve all attributes from the shortcodes tag.
225: *
226: * The attributes list has the attribute name as the key and the value of the
227: * attribute as the value in the key/value pair. This allows for easier
228: * retrieval of the attributes, since all attributes have to be known.
229: *
230: * @param string $text tag text to process
231: *
232: * @return array List of attributes and their value.
233: */
234: private function parseAttributes($text)
235: {
236: $text = preg_replace("/[\x{00a0}\x{200b}]+/u", " ", $text);
237:
238: if (!preg_match_all($this->attrPattern, $text, $matches, PREG_SET_ORDER)) {
239: return array(ltrim($text));
240: }
241:
242: $attr = array();
243:
244: foreach ($matches as $match) {
245: if (!empty($match[1])) {
246: $attr[strtolower($match[1])] = stripcslashes($match[2]);
247: } elseif (!empty($match[3])) {
248: $attr[strtolower($match[3])] = stripcslashes($match[4]);
249: } elseif (!empty($match[5])) {
250: $attr[strtolower($match[5])] = stripcslashes($match[6]);
251: } elseif (isset($match[7]) && strlen($match[7])) {
252: $attr[] = stripcslashes($match[7]);
253: } elseif (isset($match[8])) {
254: $attr[] = stripcslashes($match[8]);
255: }
256: }
257:
258: return $attr;
259: }
260:
261: /**
262: * Strips a tag leaving escaped tags
263: *
264: * @param array $tag tag expression matches
265: *
266: * @return string stripped tag
267: */
268: private function stripShortcodeTag($tag)
269: {
270: if ($tag[1] === '[' && $tag[6] === ']') {
271: return substr($tag[0], 1, -1);
272: }
273:
274: return $tag[1] . $tag[6];
275: }
276:
277: /**
278: * Retrieve the shortcode regular expression for searching.
279: *
280: * The regular expression combines the shortcode tags in the regular expression
281: * in a regex class.
282: *
283: * The regular expression contains 6 different sub matches to help with parsing.
284: *
285: * 1 - An extra [ to allow for escaping shortcodes with double [[]]
286: * 2 - The shortcode name
287: * 3 - The shortcode argument list
288: * 4 - The self closing /
289: * 5 - The content of a shortcode when it wraps some content.
290: * 6 - An extra ] to allow for escaping shortcodes with double [[]]
291: *
292: * @return string The shortcode search regular expression
293: */
294: private function shortcodeRegex()
295: {
296: $tagRegex = join('|', array_map('preg_quote', array_keys($this->shortcodes)));
297:
298: return
299: '/'
300: . '\\[' // Opening bracket
301: . '(\\[?)' // 1: Optional second opening bracket for escaping shortcodes: [[tag]]
302: . "($tagRegex)" // 2: Shortcode name
303: . '(?![\\w-])' // Not followed by word character or hyphen
304: . '(' // 3: Unroll the loop: Inside the opening shortcode tag
305: . '[^\\]\\/]*' // Not a closing bracket or forward slash
306: . '(?:'
307: . '\\/(?!\\])' // A forward slash not followed by a closing bracket
308: . '[^\\]\\/]*' // Not a closing bracket or forward slash
309: . ')*?'
310: . ')'
311: . '(?:'
312: . '(\\/)' // 4: Self closing tag ...
313: . '\\]' // ... and closing bracket
314: . '|'
315: . '\\]' // Closing bracket
316: . '(?:'
317: . '(' // 5: Unroll the loop: Optionally, anything between the opening and closing shortcode tags
318: . '[^\\[]*+' // Not an opening bracket
319: . '(?:'
320: . '\\[(?!\\/\\2\\])' // An opening bracket not followed by the closing shortcode tag
321: . '[^\\[]*+' // Not an opening bracket
322: . ')*+'
323: . ')'
324: . '\\[\\/\\2\\]' // Closing shortcode tag
325: . ')?'
326: . ')'
327: . '(\\]?)' // 6: Optional second closing bracket for escaping shortcodes: [[tag]]
328: . '/s';
329: }
330: }
331: