├── .editorconfig ├── .gitignore ├── .travis.yml ├── Gruntfile.coffee ├── assets ├── banner-1544x500.png └── banner-772x250.png ├── bin └── install-wp-tests.sh ├── classes └── plugin.php ├── css └── cws-slug-control.sass ├── images ├── README.md └── src │ └── README.md ├── js ├── cws-slug-control.coffee ├── cws-slug-control.min.js ├── cws-slug-control.min.js.map └── vendor │ └── README.md ├── languages └── cws-slug-control.pot ├── lib ├── requirements-check.php └── wp-stack-plugin.php ├── package.json ├── phpunit.xml ├── readme.md ├── slug-control.php └── tests ├── bootstrap.php ├── test-sanitization.php └── test-tests.php /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | # WordPress Coding Standards 5 | # http://make.wordpress.org/core/handbook/coding-standards/ 6 | 7 | root = true 8 | 9 | [*] 10 | charset = utf-8 11 | end_of_line = lf 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | indent_style = tab 15 | 16 | [{.jshintrc,*.json,*.yml}] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [{*.txt,wp-config-sample.php}] 21 | end_of_line = crlf 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules 3 | /release 4 | /js/*.js 5 | /js/*.map 6 | !/js/*.min.js 7 | !/js/*.min.js.map -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.3 5 | - 5.4 6 | - 5.5 7 | - 5.6 8 | 9 | env: 10 | - WP_VERSION=latest WP_MULTISITE=0 11 | - WP_VERSION=4.1.1 WP_MULTISITE=0 12 | - WP_VERSION=4.0.1 WP_MULTISITE=0 13 | 14 | before_script: 15 | - bash bin/install-wp-tests.sh wordpress_test root '' localhost $WP_VERSION 16 | 17 | script: phpunit 18 | -------------------------------------------------------------------------------- /Gruntfile.coffee: -------------------------------------------------------------------------------- 1 | module.exports = (grunt) -> 2 | 3 | # Define CoffeeScript files in one place (no path or extension) 4 | coffee_files = [ 5 | 'cws-slug-control' 6 | ] 7 | 8 | # Build some arrays and objects 9 | coffee_parse = (files) -> 10 | out = {} 11 | for file in files 12 | out["js/#{file}.js"] = "js/#{file}.coffee" 13 | out 14 | 15 | uglify_parse = (file) -> 16 | src: "js/#{file}.js" 17 | dest: "js/#{file}.min.js" 18 | sourceMapIn: "js/#{file}.js.map" 19 | 20 | coffee_uglify_files = (uglify_parse file for file in coffee_files) 21 | 22 | # Project configuration 23 | grunt.initConfig 24 | pkg: grunt.file.readJSON('package.json') 25 | 26 | coffee: 27 | options: 28 | join: yes 29 | sourceMap: yes 30 | default: 31 | files: coffee_parse coffee_files 32 | 33 | coffeelint: 34 | default: [ 'js/*.coffee' ] 35 | options: 36 | no_tabs: 37 | level: 'ignore' 38 | max_line_length: 39 | level: 'warn' 40 | indentation: 41 | level: 'ignore' 42 | 43 | jshint: 44 | default: [] 45 | options: 46 | curly: yes 47 | eqeqeq: yes 48 | immed: yes 49 | latedef: yes 50 | newcap: yes 51 | noarg: yes 52 | sub: yes 53 | undef: yes 54 | boss: yes 55 | eqnull: yes 56 | globals: 57 | exports: yes 58 | module: no 59 | 60 | uglify: 61 | options: 62 | sourceMap: yes 63 | mangle: 64 | except: [ 'jQuery' ] 65 | default: 66 | files: coffee_uglify_files 67 | 68 | compass: 69 | options: 70 | sassDir: 'css' 71 | cssDir: 'css' 72 | imagesDir: 'images' 73 | sourcemap: yes 74 | environment: 'production' 75 | 76 | phpunit: 77 | default: {} 78 | 79 | watch: 80 | php: 81 | files: [ '**/*.php' ] 82 | tasks: [ 'phpunit' ] 83 | options: 84 | debounceDelay: 5000 85 | sass: 86 | files: [ 'css/*.sass' ] 87 | tasks: [ 'compass' ] 88 | options: 89 | debounceDelay: 500 90 | scripts: 91 | files: [ 92 | 'js/**/*.coffee' 93 | 'js/vendor/**/*.js' 94 | ] 95 | tasks: [ 96 | 'coffeelint' 97 | 'coffee' 98 | 'jshint' 99 | 'uglify' 100 | 'clean:js' 101 | ] 102 | options: 103 | debounceDelay: 500 104 | 105 | wp_deploy: 106 | default: 107 | options: 108 | plugin_slug: '<%= pkg.name %>' 109 | build_dir: 'release/svn/' 110 | assets_dir: 'assets/' 111 | 112 | clean: 113 | release: [ 114 | 'release/<%= pkg.version %>/' 115 | 'release/svn/' 116 | ] 117 | js: [ 118 | 'js/*.js' 119 | '!js/*.min.js' 120 | 'js/*.src.coffee' 121 | 'js/*.js.map' 122 | '!js/*.min.js.map' 123 | ] 124 | svn_readme_md: [ 125 | 'release/svn/readme.md' 126 | ] 127 | 128 | copy: 129 | main: 130 | src: [ 131 | '**' 132 | '!node_modules/**' 133 | '!release/**' 134 | '!assets/**' 135 | '!.git/**' 136 | '!.sass-cache/**' 137 | '!js/**/*.src.coffee' 138 | '!img/src/**' 139 | '!Gruntfile.*' 140 | '!package.json' 141 | '!.gitignore' 142 | '!.gitmodules' 143 | '!tests/**' 144 | '!bin/**' 145 | '!.travis.yml' 146 | '!phpunit.xml' 147 | ] 148 | dest: 'release/<%= pkg.version %>/' 149 | svn: 150 | cwd: 'release/<%= pkg.version %>/' 151 | expand: yes 152 | src: '**' 153 | dest: 'release/svn/' 154 | 155 | replace: 156 | header: 157 | src: [ '<%= pkg.name %>.php' ] 158 | overwrite: yes 159 | replacements: [ 160 | from: /^Version:(\s*?)[\w.-]+$/m 161 | to: 'Version: <%= pkg.version %>' 162 | ] 163 | plugin: 164 | src: [ 'classes/plugin.php' ] 165 | overwrite: yes 166 | replacements: [ 167 | from: /^(\s*?)const(\s+?)VERSION(\s*?)=(\s+?)'[^']+';/m 168 | to: "$1const$2VERSION$3=$4'<%= pkg.version %>';" 169 | , 170 | from: /^(\s*?)const(\s+?)CSS_JS_VERSION(\s*?)=(\s+?)'[^']+';/m 171 | to: "$1const$2CSS_JS_VERSION$3=$4'<%= pkg.version %>';" 172 | ] 173 | svn_readme: 174 | src: [ 'release/svn/readme.md' ] 175 | dest: 'release/svn/readme.txt' 176 | replacements: [ 177 | from: /^# (.*?)( #+)?$/mg 178 | to: '=== $1 ===' 179 | , 180 | from: /^## (.*?)( #+)?$/mg 181 | to: '== $1 ==' 182 | , 183 | from: /^### (.*?)( #+)?$/mg 184 | to: '= $1 =' 185 | , 186 | from: /^Stable tag:\s*?[\w.-]+(\s*?)$/mi 187 | to: 'Stable tag: <%= pkg.version %>$1' 188 | ] 189 | 190 | compress: 191 | default: 192 | options: 193 | mode: 'zip' 194 | archive: './release/<%= pkg.name %>.<%= pkg.version %>.zip' 195 | expand: yes 196 | cwd: 'release/<%= pkg.version %>/' 197 | src: [ '**/*' ] 198 | dest: '<%= pkg.name %>/' 199 | 200 | # Load other tasks 201 | grunt.loadNpmTasks 'grunt-contrib-jshint' 202 | grunt.loadNpmTasks 'grunt-contrib-concat' 203 | grunt.loadNpmTasks 'grunt-contrib-coffee' 204 | grunt.loadNpmTasks 'grunt-coffeelint' 205 | grunt.loadNpmTasks 'grunt-contrib-uglify' 206 | grunt.loadNpmTasks 'grunt-contrib-compass' 207 | grunt.loadNpmTasks 'grunt-contrib-watch' 208 | grunt.loadNpmTasks 'grunt-contrib-clean' 209 | grunt.loadNpmTasks 'grunt-contrib-copy' 210 | grunt.loadNpmTasks 'grunt-contrib-compress' 211 | grunt.loadNpmTasks 'grunt-text-replace' 212 | grunt.loadNpmTasks 'grunt-phpunit' 213 | grunt.loadNpmTasks 'grunt-wp-deploy' 214 | 215 | # Default task 216 | grunt.registerTask 'default', [ 217 | 'replace:header' 218 | 'replace:plugin' 219 | 'coffeelint' 220 | 'coffee' 221 | 'jshint' 222 | 'uglify' 223 | 'compass' 224 | 'clean:js' 225 | ] 226 | 227 | # Build task 228 | grunt.registerTask 'build', [ 229 | 'default' 230 | 'clean' 231 | 'copy:main' 232 | 'compress' # Can comment this out for WordPress.org plugins 233 | ] 234 | 235 | # Prepare a WordPress.org release 236 | grunt.registerTask 'release:prepare', [ 237 | 'build' 238 | 'copy:svn' 239 | 'replace:svn_readme' 240 | 'clean:svn_readme_md' 241 | ] 242 | 243 | # Deploy out a WordPress.org release 244 | grunt.registerTask 'release:deploy', [ 245 | 'wp_deploy' 246 | ] 247 | 248 | # WordPress.org release task 249 | grunt.registerTask 'release', [ 250 | 'release:prepare' 251 | 'release:deploy' 252 | ] 253 | 254 | grunt.util.linefeed = '\n' 255 | 256 | -------------------------------------------------------------------------------- /assets/banner-1544x500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markjaquith/slug-control/ae50309532e792de51b40a3f4040d88611231427/assets/banner-1544x500.png -------------------------------------------------------------------------------- /assets/banner-772x250.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markjaquith/slug-control/ae50309532e792de51b40a3f4040d88611231427/assets/banner-772x250.png -------------------------------------------------------------------------------- /bin/install-wp-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ $# -lt 3 ]; then 4 | echo "usage: $0 [db-host] [wp-version]" 5 | exit 1 6 | fi 7 | 8 | DB_NAME=$1 9 | DB_USER=$2 10 | DB_PASS=$3 11 | DB_HOST=${4-localhost} 12 | WP_VERSION=${5-latest} 13 | 14 | WP_TESTS_DIR=${WP_TESTS_DIR-/tmp/wordpress-tests-lib} 15 | WP_CORE_DIR=${WP_CORE_DIR-/tmp/wordpress/} 16 | 17 | download() { 18 | if [ `which curl` ]; then 19 | curl -s "$1" > "$2"; 20 | elif [ `which wget` ]; then 21 | wget -nv -O "$2" "$1" 22 | fi 23 | } 24 | 25 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+(\.[0-9]+)? ]]; then 26 | WP_TESTS_TAG="tags/$WP_VERSION" 27 | elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 28 | WP_TESTS_TAG="trunk" 29 | else 30 | # http serves a single offer, whereas https serves multiple. we only want one 31 | download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json 32 | grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json 33 | LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') 34 | if [[ -z "$LATEST_VERSION" ]]; then 35 | echo "Latest WordPress version could not be found" 36 | exit 1 37 | fi 38 | WP_TESTS_TAG="tags/$LATEST_VERSION" 39 | fi 40 | 41 | set -ex 42 | 43 | install_wp() { 44 | 45 | if [ -d $WP_CORE_DIR ]; then 46 | return; 47 | fi 48 | 49 | mkdir -p $WP_CORE_DIR 50 | 51 | if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 52 | mkdir -p /tmp/wordpress-nightly 53 | download https://wordpress.org/nightly-builds/wordpress-latest.zip /tmp/wordpress-nightly/wordpress-nightly.zip 54 | unzip -q /tmp/wordpress-nightly/wordpress-nightly.zip -d /tmp/wordpress-nightly/ 55 | mv /tmp/wordpress-nightly/wordpress/* $WP_CORE_DIR 56 | else 57 | if [ $WP_VERSION == 'latest' ]; then 58 | local ARCHIVE_NAME='latest' 59 | else 60 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 61 | fi 62 | download https://wordpress.org/${ARCHIVE_NAME}.tar.gz /tmp/wordpress.tar.gz 63 | tar --strip-components=1 -zxmf /tmp/wordpress.tar.gz -C $WP_CORE_DIR 64 | fi 65 | 66 | download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php 67 | } 68 | 69 | install_test_suite() { 70 | # portable in-place argument for both GNU sed and Mac OSX sed 71 | if [[ $(uname -s) == 'Darwin' ]]; then 72 | local ioption='-i .bak' 73 | else 74 | local ioption='-i' 75 | fi 76 | 77 | # set up testing suite if it doesn't yet exist 78 | if [ ! -d $WP_TESTS_DIR ]; then 79 | # set up testing suite 80 | mkdir -p $WP_TESTS_DIR 81 | svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes 82 | fi 83 | 84 | cd $WP_TESTS_DIR 85 | 86 | if [ ! -f wp-tests-config.php ]; then 87 | download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php 88 | sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR':" "$WP_TESTS_DIR"/wp-tests-config.php 89 | sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php 90 | sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php 91 | sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php 92 | sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php 93 | fi 94 | 95 | } 96 | 97 | install_db() { 98 | # parse DB_HOST for port or socket references 99 | local PARTS=(${DB_HOST//\:/ }) 100 | local DB_HOSTNAME=${PARTS[0]}; 101 | local DB_SOCK_OR_PORT=${PARTS[1]}; 102 | local EXTRA="" 103 | 104 | if ! [ -z $DB_HOSTNAME ] ; then 105 | if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then 106 | EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" 107 | elif ! [ -z $DB_SOCK_OR_PORT ] ; then 108 | EXTRA=" --socket=$DB_SOCK_OR_PORT" 109 | elif ! [ -z $DB_HOSTNAME ] ; then 110 | EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" 111 | fi 112 | fi 113 | 114 | # create database 115 | mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA 116 | } 117 | 118 | install_wp 119 | install_test_suite 120 | install_db -------------------------------------------------------------------------------- /classes/plugin.php: -------------------------------------------------------------------------------- 1 | hook( 'plugins_loaded', 'add_hooks' ); 13 | } 14 | 15 | /** 16 | * Adds hooks 17 | */ 18 | public function add_hooks() { 19 | $this->hook( 'init' ); 20 | $this->hook( 'sanitize_title', 9 ); 21 | 22 | // This plugin's internal sanitization filters 23 | $this->hook( 'cws_tc_sanitize_title', 'uncontraction' ); 24 | $this->hook( 'cws_tc_sanitize_title', 'percentify' ); 25 | $this->hook( 'cws_tc_sanitize_title', 'unprependify' ); 26 | $this->hook( 'cws_tc_sanitize_title', 'rangerific' ); 27 | $this->hook( 'cws_tc_sanitize_title', 'ampersandy' ); 28 | } 29 | 30 | /** 31 | * Initializes the plugin, registers textdomain, etc 32 | */ 33 | public function init() { 34 | $this->load_textdomain( 'cws-slug-control', '/languages' ); 35 | } 36 | 37 | /** 38 | * Expands English language contractions to avoid ambiguity 39 | * 40 | * @param string $title The post title 41 | * @return string The modified post title 42 | */ 43 | public function uncontraction( $title ) { 44 | $apos = "(?:'|’)"; 45 | $contractions = array( 46 | "(can){$apos}t" => '%snot', 47 | "(it|that|s?he|who){$apos}s" => '%s is', 48 | "(they|we|you|what|who){$apos}re" => '%s are', 49 | "(it|that|s?he|i|you|we|what|who|why){$apos}s" => '%s will', 50 | "(were|is|[cw]ould|should|might|must|had|has|have|do)n{$apos}t" => '%s not', 51 | "([cw]ould|should|might|must|we|you|i|they|what|where){$apos}ve" => '%s have', 52 | ); 53 | foreach ( $contractions as $from => $to ) { 54 | $from = '#\b' . $from . '\b#i'; 55 | $to = sprintf( $to, '$1' ); 56 | // echo "Replacing $from, with $to
"; 57 | $title = preg_replace( $from, $to, $title ); 58 | } 59 | return $title; 60 | } 61 | 62 | /** 63 | * Spells out percentages explicitly. 10% => 10 percent 64 | * 65 | * @param string $title The post title 66 | * @return string The modified post title 67 | */ 68 | public function percentify( $title ) { 69 | return preg_replace( '#\b(\d+)%(\s|$)#', '$1 percent$2', $title ); 70 | } 71 | 72 | /** 73 | * Remove prepended phrased like "Breaking news:" 74 | * 75 | * @param string $title The post title 76 | * @return string The modified post title 77 | */ 78 | public function unprependify( $title ) { 79 | return preg_replace( '#^((developing|breaking)( news)?|update(s|d)?):\s#i', '', $title ); 80 | } 81 | 82 | /** 83 | * Changes ranges like 0-60 to "0 to 60" 84 | * 85 | * @param string $title The post title 86 | * @return string The modified post title 87 | */ 88 | public function rangerific( $title ) { 89 | return preg_replace( '#(\s|^)(\d+)([-–—]+)(\d+)(\s|$)#', '$1$2 to $4$5', $title ); 90 | } 91 | 92 | /** 93 | * Changes ampersands to "and" 94 | * 95 | * @param string $title The post title 96 | * @return string The modified post title 97 | */ 98 | public function ampersandy( $title ) { 99 | return preg_replace( '#(\s)&(?:amp;)?(\s)#', '$1and$2', $title ); 100 | } 101 | 102 | /** 103 | * Callback for sanitize_title filter 104 | * 105 | * @param string $title The post title 106 | * @param string $raw_title The original post title 107 | * @param string $context The context 108 | * @return string The modified title 109 | */ 110 | public function sanitize_title( $title, $raw_title, $context ) { 111 | if ( 'display' === $context || 'save' === $context ) { 112 | $title = apply_filters( 'cws_tc_sanitize_title', $title, $raw_title, $context ); 113 | } 114 | return $title; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /css/cws-slug-control.sass: -------------------------------------------------------------------------------- 1 | /** 2 | * Slug Control 3 | * 4 | * 5 | * Copyright (c) 2015 Mark Jaquith 6 | * Licensed under the GPLv2+ license. 7 | */ -------------------------------------------------------------------------------- /images/README.md: -------------------------------------------------------------------------------- 1 | # Project Images 2 | 3 | Only images in use by the project should be placed in this folder. Wherever possible, combine multiple small images into sprites to be used by CSS. Original (non-sprite) images should be placed in the `/src` subdirectory. -------------------------------------------------------------------------------- /images/src/README.md: -------------------------------------------------------------------------------- 1 | # Project Images 2 | 3 | Only source images (i.e. non-sprites, PSDs, raw photos) should be placed in this directory. Source files are meant to serve as a backup for any images that can be edited by an end user. -------------------------------------------------------------------------------- /js/cws-slug-control.coffee: -------------------------------------------------------------------------------- 1 | ### 2 | * Slug Control 3 | * 4 | * Copyright (c) 2015 Mark Jaquith 5 | * Licensed under the GPLv2+ license. 6 | ### 7 | 8 | -------------------------------------------------------------------------------- /js/cws-slug-control.min.js: -------------------------------------------------------------------------------- 1 | (function(){}).call(this); 2 | //# sourceMappingURL=cws-slug-control.min.js.map -------------------------------------------------------------------------------- /js/cws-slug-control.min.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"cws-slug-control.min.js","sources":["cws-slug-control.js"],"names":["call","this"],"mappings":"CAQA,cAGGA,KAAKC"} -------------------------------------------------------------------------------- /js/vendor/README.md: -------------------------------------------------------------------------------- 1 | # Vendor Scripts 2 | 3 | Place each vendor JavaScript project in a separate subdirectory of this folder. Vendor scripts are not run through JSHint by Grunt, but *can* be dynamically concatenated with other project scripts by adding them to the `concat` section of `Gruntfile.js`. -------------------------------------------------------------------------------- /languages/cws-slug-control.pot: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Project-Id-Version: Slug Control\n" 4 | "POT-Creation-Date: 2015-04-19 12:17-0400\n" 5 | "PO-Revision-Date: 2015-04-19 12:17-0400\n" 6 | "Last-Translator: Mark Jaquith \n" 7 | "Language-Team: \n" 8 | "MIME-Version: 1.0\n" 9 | "Content-Type: text/plain; charset=UTF-8\n" 10 | "Content-Transfer-Encoding: 8bit\n" 11 | "X-Poedit-KeywordsList: __;_e;__ngettext:1,2;_n:1,2;__ngettext_noop:1,2;" 12 | "_n_noop:1,2;_x:1,2c;_nx:4c,1,2;_nx_noop:4c,1,2;_ex:1,2c;" 13 | "esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n" 14 | "X-Poedit-Basepath: .\n" 15 | "X-Poedit-SearchPath-0: ..\n" 16 | 17 | -------------------------------------------------------------------------------- /lib/requirements-check.php: -------------------------------------------------------------------------------- 1 | $setting = $args[$setting]; 13 | } 14 | } 15 | } 16 | 17 | public function passes() { 18 | $passes = $this->php_passes() && $this->wp_passes(); 19 | if ( ! $passes ) { 20 | add_action( 'admin_notices', array( $this, 'deactivate' ) ); 21 | } 22 | return $passes; 23 | } 24 | 25 | public function deactivate() { 26 | if ( isset( $this->file ) ) { 27 | deactivate_plugins( plugin_basename( $this->file ) ); 28 | } 29 | } 30 | 31 | private function php_passes() { 32 | if ( $this->__php_at_least( $this->php ) ) { 33 | return true; 34 | } else { 35 | add_action( 'admin_notices', array( $this, 'php_version_notice' ) ); 36 | return false; 37 | } 38 | } 39 | 40 | private static function __php_at_least( $min_version ) { 41 | return version_compare( phpversion(), $min_version, '>=' ); 42 | } 43 | 44 | public function php_version_notice() { 45 | echo '
'; 46 | echo "

The “" . esc_html( $this->title ) . "” plugin cannot run on PHP versions older than " . $this->php . '. Please contact your host and ask them to upgrade.

'; 47 | echo '
'; 48 | } 49 | 50 | private function wp_passes() { 51 | if ( $this->__wp_at_least( $this->wp ) ) { 52 | return true; 53 | } else { 54 | add_action( 'admin_notices', array( $this, 'wp_version_notice' ) ); 55 | return false; 56 | } 57 | } 58 | 59 | private static function __wp_at_least( $min_version ) { 60 | return version_compare( get_bloginfo( 'version' ), $min_version, '>=' ); 61 | } 62 | 63 | public function wp_version_notice() { 64 | echo '
'; 65 | echo "

The “" . esc_html( $this->title ) . "” plugin cannot run on WordPress versions older than " . $this->wp . '. Please update WordPress.

'; 66 | echo '
'; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /lib/wp-stack-plugin.php: -------------------------------------------------------------------------------- 1 | __FILE__ = $__FILE__; 25 | } 26 | return static::get_instance(); 27 | } 28 | 29 | /** 30 | * Returns the plugin's object instance 31 | * 32 | * @return object the plugin object instance 33 | */ 34 | public static function get_instance() { 35 | if ( isset( static::$instance ) ) { 36 | return static::$instance; 37 | } 38 | } 39 | 40 | /** 41 | * Add a WordPress hook (action/filter) 42 | * 43 | * @param mixed $hook,... first parameter is the name of the hook. If second or third parameters are included, they will be used as a priority (if an integer) or as a class method callback name (if a string) 44 | */ 45 | public function hook( $hook ) { 46 | $priority = 10; 47 | $method = $this->sanitize_method( $hook ); 48 | $args = func_get_args(); 49 | unset( $args[0] ); 50 | foreach( (array) $args as $arg ) { 51 | if ( is_int( $arg ) ) 52 | $priority = $arg; 53 | else 54 | $method = $arg; 55 | } 56 | return add_action( $hook, array( $this, $method ), $priority, 999 ); 57 | } 58 | 59 | private function sanitize_method( $method ) { 60 | return str_replace( array( '.', '-' ), array( '_DOT_', '_DASH_' ), $method ); 61 | } 62 | 63 | /** 64 | * Includes a file (relative to the plugin base path) 65 | * and optionally globalizes a named array passed in 66 | * 67 | * @param string $file the file to include 68 | * @param array $data a named array of data to globalize 69 | */ 70 | protected function include_file( $file, $data = array() ) { 71 | extract( $data, EXTR_SKIP ); 72 | include( $this->get_path() . $file ); 73 | } 74 | 75 | /** 76 | * Returns the URL to the plugin directory 77 | * 78 | * @return string the URL to the plugin directory 79 | */ 80 | public function get_url() { 81 | return plugin_dir_url( $this->__FILE__ ); 82 | } 83 | 84 | /** 85 | * Returns the path to the plugin directory 86 | * 87 | * @return string the absolute path to the plugin directory 88 | */ 89 | public function get_path() { 90 | return plugin_dir_path( $this->__FILE__ ); 91 | } 92 | 93 | public function load_textdomain( $name, $path ) { 94 | return load_plugin_textdomain( $name, false, basename( dirname( $this->__FILE__ ) ) . $path ); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slug-control", 3 | "title": "Slug Control", 4 | "description": "Helps you craft amazing post URL slugs, for that hand-crafted URL feel.", 5 | "version": "0.1.1", 6 | "homepage": "", 7 | "author": { 8 | "name": "Mark Jaquith", 9 | "email": "mark@jaquith.me", 10 | "url": "http://markjaquith.com/" 11 | }, 12 | "devDependencies": { 13 | "grunt": "~0.4.5", 14 | "grunt-contrib-concat": "~0.5.0", 15 | "grunt-contrib-coffee": "~0.13.0", 16 | "grunt-coffeelint": "~0.0.13", 17 | "grunt-contrib-uglify": "~0.6.0", 18 | "grunt-contrib-compass": "~1.0.1", 19 | "grunt-contrib-jshint": "~0.10.0", 20 | "grunt-contrib-nodeunit": "~0.4.1", 21 | "grunt-contrib-watch": "~0.6.1", 22 | "grunt-contrib-clean": "~0.6.0", 23 | "grunt-contrib-copy": "~0.7.0", 24 | "grunt-contrib-compress": "~0.12.0", 25 | "grunt-text-replace": "~0.4.0", 26 | "grunt-phpunit": "~0.3.6", 27 | "grunt-wp-deploy": "~1.0.3", 28 | "coffeelint": "^1" 29 | }, 30 | "keywords": [] 31 | } -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | ./tests/ 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Slug Control # 2 | 3 | [![Build Status](https://travis-ci.org/markjaquith/slug-control.svg?branch=master)](https://travis-ci.org/markjaquith/slug-control) 4 | 5 | Contributors: markjaquith 6 | Donate link: http://txfx.net/wordpress-plugins/donate 7 | Tags: slug, post slug, urls 8 | Requires at least: 4.0 9 | Tested up to: 4.2 10 | Stable tag: 0.1.0 11 | License: GPLv2 or later 12 | License URI: http://www.gnu.org/licenses/gpl-2.0.html 13 | 14 | Helps you craft amazing post URL slugs, for that hand-crafted URL feel. 15 | 16 | ## Description ## 17 | 18 | Isn't it annoying when you craft a post title, and then the URL slug kind of falls short? Like, if your title is **Breaking: Yankees win 3-2 in extra innings. I'm excited & literally can't even** and your post slug turns out like **breaking-yankees-win-3-2-in-extra-innings-im-excited-literally-cant-even**. "3-2" is ambiguous because all the words are already separated by dashes. "im" is not a word. "cant" means something else. The ampersand meaning "and" just got dropped. The leading "breaking-" will be silly when the date part of the URL is 3 years old. The whole URL is off. Doesn't this make more sense? **yankees-win-3-to-2-in-extra-innings-i-am-excited-and-literally-cannot-even**. Sure it's really long (and a future version of the plugin will help with that!), but it reads much better. 19 | 20 | ## Installation ## 21 | 22 | Search for "Slug Control" and install it. 23 | 24 | ### Manual Installation ### 25 | 26 | 1. Upload the entire `/cws-slug-control` directory to the `/wp-content/plugins/` directory. 27 | 2. Activate Slug Control through the 'Plugins' menu in WordPress. 28 | 29 | ## Frequently Asked Questions ## 30 | 31 | ### Are there any options? ### 32 | 33 | Not yet. This is an initial release to test the idea. 34 | 35 | ## Contribute ## 36 | 37 | Contribute to this plugin on GitHub: 38 | 39 | https://github.com/markjaquith/slug-control/issues 40 | 41 | ## Changelog ## 42 | 43 | ### 0.1 ### 44 | * First release 45 | 46 | ## Upgrade Notice ## 47 | 48 | ### 0.1.0 ### 49 | First release 50 | -------------------------------------------------------------------------------- /slug-control.php: -------------------------------------------------------------------------------- 1 | 'Slug Control', 38 | 'php' => '5.3', 39 | 'wp' => '4.0', 40 | 'file' => __FILE__, 41 | )); 42 | 43 | if ( $cws_slug_control_requirements_check->passes() ) { 44 | // Pull in the plugin classes and initialize 45 | include( dirname( __FILE__ ) . '/lib/wp-stack-plugin.php' ); 46 | include( dirname( __FILE__ ) . '/classes/plugin.php' ); 47 | CWS_Slug_Control_Plugin::start( __FILE__ ); 48 | } 49 | 50 | unset( $cws_slug_control_requirements_check ); 51 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | 'I literally cannot even', 8 | 'It\'s going down for real' => 'It is going down for real', 9 | 'Don\'t cry for me' => 'Do not cry for me', 10 | 'WE\'RE OUT OF CONTROL' => 'WE are OUT OF CONTROL', // mixed case expected 11 | // Curly quote 12 | 'I literally can’t even' => 'I literally cannot even', 13 | 'It’s going down for real' => 'It is going down for real', 14 | ); 15 | foreach ( $tests as $in => $out ) { 16 | $this->assertEquals( $this->plugin()->uncontraction( $in ), $out ); 17 | } 18 | } 19 | 20 | public function test_rangerific() { 21 | $tests = array( 22 | // hyphen-minus 23 | '0-60 in 3 seconds' => '0 to 60 in 3 seconds', 24 | 'From 0-60 in 3 seconds' => 'From 0 to 60 in 3 seconds', 25 | 'Yankees win, 6-3' => 'Yankees win, 6 to 3', 26 | 'Yankees win, 12-3 in extra innings' => 'Yankees win, 12 to 3 in extra innings', 27 | // en-dash 28 | '0–60 in 3 seconds' => '0 to 60 in 3 seconds', 29 | 'From 0–60 in 3 seconds' => 'From 0 to 60 in 3 seconds', 30 | 'Yankees win, 6–3' => 'Yankees win, 6 to 3', 31 | 'Yankees win, 12–3 in extra innings' => 'Yankees win, 12 to 3 in extra innings', 32 | // em-dash 33 | '0—60 in 3 seconds' => '0 to 60 in 3 seconds', 34 | 'From 0—60 in 3 seconds' => 'From 0 to 60 in 3 seconds', 35 | 'Yankees win, 6—3' => 'Yankees win, 6 to 3', 36 | 'Yankees win, 12—3 in extra innings' => 'Yankees win, 12 to 3 in extra innings', 37 | // multiple hyphens-minus 38 | '0--60 in 3 seconds' => '0 to 60 in 3 seconds', 39 | 'From 0--60 in 3 seconds' => 'From 0 to 60 in 3 seconds', 40 | 'Yankees win, 6--3' => 'Yankees win, 6 to 3', 41 | 'Yankees win, 12--3 in extra innings' => 'Yankees win, 12 to 3 in extra innings', 42 | ); 43 | foreach ( $tests as $in => $out ) { 44 | $this->assertEquals( $this->plugin()->rangerific( $in ), $out ); 45 | } 46 | } 47 | 48 | public function test_percentify() { 49 | $tests = array( 50 | '60% of the time, it works every time' => '60 percent of the time, it works every time', 51 | 'Between 5% and 15% of people' => 'Between 5 percent and 15 percent of people', 52 | 'Almost 100%' => 'Almost 100 percent', 53 | ); 54 | foreach ( $tests as $in => $out ) { 55 | $this->assertEquals( $this->plugin()->percentify( $in ), $out ); 56 | } 57 | } 58 | 59 | public function test_unprependify() { 60 | $tests = array( 61 | 'Breaking: thing happened' => 'thing happened', 62 | 'Updated: Foo' => 'Foo', 63 | 'Developing: Bar' => 'Bar', 64 | 'breaking news: calamity' => 'calamity', 65 | ); 66 | foreach ( $tests as $in => $out ) { 67 | $this->assertEquals( $this->plugin()->unprependify( $in ), $out ); 68 | } 69 | } 70 | 71 | public function test_ampersandy() { 72 | $tests = array( 73 | '& nope nope nope' => '& nope nope nope', 74 | 'nope &' => 'nope &', 75 | 'Simon & Garfunkle' => 'Simon and Garfunkle', 76 | 'Simon & Garfunkle' => 'Simon and Garfunkle', 77 | ); 78 | foreach ( $tests as $in => $out ) { 79 | $this->assertEquals( $this->plugin()->ampersandy( $in ), $out ); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/test-tests.php: -------------------------------------------------------------------------------- 1 | assertTrue( true ); 6 | } 7 | } 8 | --------------------------------------------------------------------------------