├── .gitignore ├── LICENSE ├── includes ├── class-auto-link.php ├── class-wp-auto-linker.php └── class-auto-linker.php ├── wp-auto-linker.php └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Deployed apps should consider commenting this line out: 24 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 25 | node_modules 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 John James Jacoby 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 | -------------------------------------------------------------------------------- /includes/class-auto-link.php: -------------------------------------------------------------------------------- 1 | '', 42 | 'filter_single' => '', 43 | 'filter_no_match' => '' 44 | ); 45 | 46 | /** 47 | * Array of attributes used to intelligently parse output 48 | * 49 | * @var array 50 | */ 51 | public $output = array( 52 | 'filter_all' => '', 53 | 'filter_single' => '', 54 | 'filter_no_match' => '' 55 | ); 56 | 57 | /** 58 | * Assign properties on new 59 | * 60 | * @param string $name Human readible name, used by any UI objects 61 | * @param string $char Prefix character used to link to another object 62 | * @param array $input Array of attributes used to intelligently parse input 63 | * @param array $output Array of attributes used to intelligently parse output 64 | */ 65 | public function __construct( $name = '', $char = '', $input = array(), $output = array() ) { 66 | $this->name = $name; 67 | $this->character = $char; 68 | $this->input = $input; 69 | $this->output = $output; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /wp-auto-linker.php: -------------------------------------------------------------------------------- 1 | add_linker( array( 47 | 'name' => esc_html__( 'Author Archives', 'wp-auto-linker' ), 48 | 'char' => '@', 49 | 'output' => array( 50 | 'filter_single' => array( $linker, 'single_user_link' ) 51 | ), 52 | 'input' => false 53 | ) ); 54 | 55 | // Post Tags 56 | $linker->add_linker( array( 57 | 'name' => esc_html__( 'Post Tags', 'wp-auto-linker' ), 58 | 'char' => '#', 59 | 'output' => array( 60 | 'filter_single' => array( $linker, 'single_post_tag_link' ) 61 | ), 62 | 'input' => array( 63 | 'filter_all' => array( $linker, 'save_all_post_tags' ), 64 | 'filter_no_match' => array( $linker, 'save_no_match_post_tags' ) 65 | ) 66 | ) ); 67 | 68 | // Categories 69 | $linker->add_linker( array( 70 | 'name' => esc_html__( 'Categories', 'wp-auto-linker' ), 71 | 'char' => '$', 72 | 'output' => array( 73 | 'filter_single' => array( $linker, 'single_category_link' ) 74 | ), 75 | 'input' => array( 76 | 'filter_all' => array( $linker, 'save_all_categories' ), 77 | 'filter_no_match' => array( $linker, 'save_no_match_categories' ) 78 | ) 79 | ) ); 80 | 81 | // Pages 82 | $linker->add_linker( array( 83 | 'name' => esc_html__( 'Pages', 'wp-auto-linker' ), 84 | 'char' => '^', 85 | 'output' => array( 86 | 'filter_single' => array( $linker, 'single_page' ) 87 | ), 88 | 'input' => false 89 | ) ); 90 | } 91 | add_action( 'plugins_loaded', 'wp_auto_linker_setup_default_links', 11 ); 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WP Auto Linker 2 | 3 | Automatically link keywords to users, tags, categories, pages, and more. Associate key characters with URL patterns, and automatically link words in any output. 4 | 5 | WP Auto Linker introduces a basic "hashtag" system, making it easy to tag, categorize, and automatically link to things in your content. 6 | 7 | # Installation 8 | 9 | * Download and install using the built in WordPress plugin installer. 10 | * Activate in the "Plugins" area of your admin by clicking the "Activate" link. 11 | * No further setup or configuration is necessary. 12 | 13 | # FAQ 14 | 15 | ### What is all supported? 16 | 17 | * `@` to link to users 18 | * `#` to link to post tags 19 | * `$` to link to post categories 20 | * `^` to link to pages 21 | 22 | ### Examples 23 | 24 | ``` 25 | Hey everyone! @admin just created the ^about page. #tgif 26 | ``` 27 | 28 | ``` 29 | Hey @bob, can you triage the comments on posts in $kanyewest? 30 | ``` 31 | 32 | ### How do I create a custom linker? 33 | 34 | Something like: 35 | 36 | ``` 37 | // Instantiate the linker 38 | $linker = new WP_Auto_Linker(); 39 | 40 | // Author Archives 41 | $linker->add_linker( array( 42 | 'name' => esc_html__( 'Single Posts', 'wp-auto-linker' ), 43 | 'char' => '!', 44 | 'output' => array( 45 | 'filter_single' => array( $linker, 'single_post_link' ) 46 | ), 47 | 'input' => false 48 | ) ); 49 | ``` 50 | 51 | * Name - Not used, but could be used for a UI 52 | * Char - The control character used on input & output 53 | * Output - Accepts an array of filter callbacks for single, all, or no matches 54 | * Input - Accepts an array of filter callbacks for single, all, or no matches 55 | 56 | For now, you'll want to create your own callbacks for input & output. As this matures, ideally we'll have a collection of them that can be conveniently hooked into for any post type, taxonomy, etc... 57 | 58 | ### Is this performant? 59 | 60 | On input, it's pretty performant. All it does is parse through looking for taxonomy terms to add to posts. 61 | 62 | On output, it's maybe less-so, as `the_content` is filtered and links are applied. The more you link, the more objects need to be pulled up so they can be linked to. If you're caching objects and output, this shouldn't really matter to you. You are caching; right? 63 | 64 | ### Can I extend this for my own objects? 65 | 66 | Yes! The main Autolinker class is flexible enough to be used on and for anything, and the `wp_auto_linker_setup_default_links()` function is a good example of how you might link your custom post-types & taxonomies together. 67 | 68 | ### Does this create new database tables? 69 | 70 | No. It uses WordPress's custom post-type, custom taxonomy, and metadata APIs. 71 | 72 | ### Does this modify existing database tables? 73 | 74 | No. All of WordPress's core database tables remain untouched. 75 | 76 | ### Where can I get support? 77 | 78 | The WordPress support forums: https://wordpress.org/plugins/tags/wp-auto-linker/ 79 | 80 | ### Can I contribute? 81 | 82 | Yes, please! 83 | -------------------------------------------------------------------------------- /includes/class-wp-auto-linker.php: -------------------------------------------------------------------------------- 1 | post_content, $post ); 59 | } 60 | 61 | /** Input Functions *******************************************************/ 62 | 63 | /** 64 | * Save all tags in a post 65 | * 66 | * @param array $tags Array of tags 67 | * @param string $content Not used 68 | * @param object $post The post being saved 69 | */ 70 | protected function save_all_post_tags( $tags = '', $content = '', $post = false ) { 71 | $this->set_object_terms( get_post( $post ), $tags, 'post_tag' ); 72 | } 73 | 74 | /** 75 | * Remove all tags from a post 76 | * 77 | * @param string $content Not used 78 | * @param object $post The post being saved 79 | */ 80 | protected function save_no_match_post_tags( $content = '', $post = false ) { 81 | $this->set_object_terms( get_post( $post ), '', 'post_tag' ); 82 | } 83 | 84 | /** 85 | * Save all categories in a post 86 | * 87 | * @param array $categories Array of categories 88 | * @param string $content Not used 89 | * @param object $post The post being saved 90 | */ 91 | protected function save_all_categories( $categories = '', $content = '', $post = false ) { 92 | $this->set_object_terms( get_post( $post ), $categories, 'category' ); 93 | } 94 | 95 | /** 96 | * No categories in this post 97 | * 98 | * @param string $content Not used 99 | * @param object $post The post being saved 100 | */ 101 | protected function save_no_match_categories( $content = '', $post = false ) { 102 | 103 | // Get the post 104 | $post = get_post( $post ); 105 | 106 | // Bail if category is already set 107 | if ( get_the_category( $post ) ) { 108 | return; 109 | } 110 | 111 | // Get the default category 112 | $default_category = get_term( get_option( 'default_category', 'Uncategorized' ), 'category' )->slug; 113 | 114 | // Set object terms 115 | $this->set_object_terms( $post, $default_category, 'category' ); 116 | } 117 | 118 | /** 119 | * Set terms for an object 120 | * 121 | * @param object $object 122 | * @param array $terms 123 | * @param string $taxonomy 124 | */ 125 | private function set_object_terms( $object, $terms = '', $taxonomy = '' ) { 126 | if ( is_object_in_taxonomy( $object, $taxonomy ) ) { 127 | wp_set_object_terms( $object->ID, $terms, $taxonomy, false ); 128 | } 129 | } 130 | 131 | /** Output Functions ******************************************************/ 132 | 133 | /** 134 | * Get a single `post_tag` taxonomy link 135 | * 136 | * @param string $match 137 | * 138 | * @return mixed 139 | */ 140 | protected function single_post_tag_link( $match = '' ) { 141 | return $this->get_term_link( $match, 'post_tag' ); 142 | } 143 | 144 | /** 145 | * Get a single `category` taxonomy link 146 | * 147 | * @param string $match 148 | * 149 | * @return mixed 150 | */ 151 | protected function single_category_link( $match = '' ) { 152 | return $this->get_term_link( $match, 'category' ); 153 | } 154 | 155 | /** 156 | * Get a single term by it's slug & taxonomy 157 | * 158 | * @param string $match 159 | * @param string $taxonomy 160 | * 161 | * @return mixed 162 | */ 163 | protected function get_term_link( $match = '', $taxonomy = '' ) { 164 | $object = get_term_by( 'slug', $match, $taxonomy ); 165 | 166 | return ( ! empty( $object ) ) 167 | ? get_term_link( $object, $taxonomy ) 168 | : false; 169 | } 170 | 171 | /** 172 | * Get a single user by it's slug 173 | * 174 | * @param string $match 175 | * 176 | * @return mixed 177 | */ 178 | protected function single_user_link( $match = '' ) { 179 | $object = get_user_by( 'slug', $match ); 180 | 181 | return ( ! empty( $object->ID ) ) 182 | ? get_author_posts_url( $object->ID ) 183 | : false; 184 | } 185 | 186 | /** 187 | * Get a single page by it's path 188 | * 189 | * @param string $match 190 | * 191 | * @return mixed 192 | */ 193 | protected function single_page( $match = '' ) { 194 | $object = get_page_by_path( $match ); 195 | 196 | return ( ! empty( $object->ID ) ) 197 | ? get_the_permalink( $object->ID ) 198 | : false; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /includes/class-auto-linker.php: -------------------------------------------------------------------------------- 1 | setup_linkers( $linkers ); 49 | } 50 | } 51 | 52 | /** 53 | * Setup linkers 54 | * 55 | * @param array $linkers 56 | */ 57 | protected function setup_linkers( $linkers = array() ) { 58 | 59 | // Bail if no linkers passed 60 | if ( empty( $linkers ) ) { 61 | return; 62 | } 63 | 64 | // Loop through and setup linkers 65 | foreach ( (array) $linkers as $linker ) { 66 | $this->add_linker( $linker ); 67 | } 68 | } 69 | 70 | /** 71 | * Add a linker to the linkers array 72 | * 73 | * @param array $linker 74 | */ 75 | public function add_linker( $linker = array() ) { 76 | $this->linkers[ $linker['char'] ] = new Autolink( 77 | $linker['name'], 78 | $linker['char'], 79 | $linker['input'], 80 | $linker['output'] 81 | ); 82 | } 83 | 84 | /** 85 | * Remove a linker from the linkers array 86 | * 87 | * @param array $linker 88 | */ 89 | public function remove_linker( $linker = array() ) { 90 | if ( isset( $linker['char'] ) && isset( $this->linkers[ $linker['char'] ] ) ) { 91 | unset( $this->linkers[ $linker['char'] ] ); 92 | } 93 | } 94 | 95 | /** 96 | * Accept some input 97 | * 98 | * @param string $content 99 | * @param string $object 100 | */ 101 | protected function input( $content = '', $object = false ) { 102 | 103 | // Get matches and bail if none exist 104 | $matches = $this->find_matches( $content ); 105 | if ( empty( $matches ) ) { 106 | if ( ! empty( $this->linkers ) ) { 107 | foreach ( array_values( $this->linkers ) as $linker ) { 108 | if ( ! empty( $linker->input['filter_no_match'] ) && is_callable( $linker->input['filter_no_match'] ) ) { 109 | call_user_func( $linker->input['filter_no_match'], $content, $object ); 110 | } 111 | } 112 | } 113 | 114 | return; 115 | } 116 | 117 | // Get rules and setup the sorted matches array 118 | $sorted = array(); 119 | 120 | // Loop through matches and sort strings into arrays keyed by hash 121 | foreach ( (array) $matches as $match ) { 122 | 123 | // Get the matched hash 124 | $char = substr( $match, 0, 1 ); 125 | $string = substr( $match, 1, strlen( $match ) - 1 ); 126 | 127 | // Add the string to the sorted hash array 128 | if ( ! empty( $this->linkers[ $char ]->input['filter_all'] ) && is_callable( $this->linkers[ $char ]->input['filter_all'] ) ) { 129 | $sorted[ $char ][] = $string; 130 | } 131 | 132 | // Check for matching URL 133 | if ( ! empty( $this->linkers[ $char ]->input['filter_single'] ) && is_callable( $this->linkers[ $char ]->input['filter_single'] ) ) { 134 | call_user_func( $this->linkers[ $char ]->input['filter_single'], $match, $content, $object ); 135 | } 136 | } 137 | 138 | // Loop through sorted hashes and call input function 139 | foreach ( $sorted as $sorted_hash => $sorted_strings ) { 140 | call_user_func( $this->linkers[ $sorted_hash ]->input['filter_all'], $sorted_strings, $content, $object ); 141 | } 142 | } 143 | 144 | /** 145 | * Return content 146 | * 147 | * @param string $content 148 | * 149 | * @return string 150 | */ 151 | protected function output( $content = '' ) { 152 | 153 | // Look for matches, and maybe filter if none are found 154 | $matches = $this->find_matches( $content ); 155 | if ( empty( $matches ) ) { 156 | if ( ! empty( $this->linkers ) ) { 157 | foreach ( array_values( $this->linkers ) as $linker ) { 158 | if ( ! empty( $linker->output['filter_no_match'] ) && is_callable( $linker->output['filter_no_match'] ) ) { 159 | $content = call_user_func( $linker->output['filter_no_match'], $content ); 160 | } 161 | } 162 | } 163 | 164 | // Return content, possibly filtered by no_match callbacks 165 | return $content; 166 | } 167 | 168 | // Get rules and setup the sorted matches array 169 | $sorted = array(); 170 | 171 | // Loop through usernames and link to profiles 172 | foreach ( (array) $matches as $match ) { 173 | 174 | // Get the matched hash 175 | $char = substr( $match, 0, 1 ); 176 | $string = substr( $match, 1, strlen( $match ) - 1 ); 177 | 178 | // Add the string to the sorted hash array 179 | if ( ! empty( $this->linkers[ $char ]->output['filter_all'] ) && is_callable( $this->linkers[ $char ]->output['filter_all'] ) ) { 180 | $sorted[ $char ][] = $string; 181 | } 182 | 183 | // Check for matching method 184 | if ( ! empty( $this->linkers[ $char ]->output['filter_single'] ) && is_callable( $this->linkers[ $char ]->output['filter_single'] ) ) { 185 | 186 | // Check for matching URL 187 | $url = call_user_func( $this->linkers[ $char ]->output['filter_single'], $string ); 188 | if ( ! empty( $url ) ) { 189 | $content = str_replace( $match, sprintf( '%2$s', $url, $match ), $content ); 190 | } 191 | } 192 | } 193 | 194 | // Loop through sorted hashes and call input function 195 | foreach ( $sorted as $sorted_hash => $sorted_strings ) { 196 | $content = call_user_func( $this->linkers[ $sorted_hash ]->output['filter_all'], $sorted_strings ); 197 | } 198 | 199 | // Return modified content 200 | return $content; 201 | } 202 | 203 | /** 204 | * Find matches in a string 205 | * 206 | * @param string $content 207 | * 208 | * @return boolean 209 | */ 210 | protected function find_matches( $content = '' ) { 211 | 212 | // Bail if no linkers to match 213 | if ( empty( $this->linkers ) ) { 214 | return false; 215 | } 216 | 217 | // Get the linkers to match 218 | $matches = implode( array_keys( $this->linkers ), ',' ); 219 | $pattern = $this->get_match_pattern( $matches ); 220 | 221 | // Attempt to match our linkers to some text 222 | preg_match_all( $pattern, $content, $matches ); 223 | 224 | // Use the matches that include prefix chars 225 | return array_filter( $matches[0] ); 226 | } 227 | 228 | /** 229 | * Setup the pattern to match against 230 | * 231 | * @return string 232 | */ 233 | protected function get_match_pattern( $matches = '' ) { 234 | return "/[{$matches}]+([A-Za-z0-9-_\.{$matches}]+)\b(?![^<]*>|[^<>]*<\/)/"; 235 | } 236 | } 237 | --------------------------------------------------------------------------------