├── .gitignore ├── views ├── missing.html ├── home.html ├── weather.html └── index.php ├── js ├── home.js ├── missing.js ├── main.js └── weather.js ├── less ├── main.less ├── _loading.less └── _mixins.less ├── package.json ├── README.md ├── gulpfile.js ├── wordpress-angular-plugin.php ├── tutorial.md └── vendor ├── angular-sanitize.js └── angular-resource.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac OS 2 | .idea/* 3 | .codekit-cache 4 | .DS_Store 5 | .AppleDouble 6 | .LSOverride 7 | 8 | # Node.js/Gulp 9 | node_modules 10 | .gulp-cache 11 | 12 | # Build 13 | dist/**/*.css 14 | dist/**/*.js 15 | dist/index.html -------------------------------------------------------------------------------- /views/missing.html: -------------------------------------------------------------------------------- 1 |
2 |

Page Not Found

3 |

The page you've requested doesn't exist anymore: {{ currentUrl }}. Try visiting the home index to find our more about what this site is, and how you can find what you're looking for.

4 |
-------------------------------------------------------------------------------- /js/home.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Home View 3 | */ 4 | 5 | // Route 6 | mainApp.config( function( $stateProvider, WP ) { 7 | $stateProvider.state( { 8 | name: 'home', 9 | url: '/', 10 | templateUrl: WP.plugin_url + '/views/home.html', 11 | controller: 'homeCtrl' 12 | } ); 13 | } ); 14 | 15 | // Controller 16 | mainApp.controller( 'homeCtrl', function( $scope ) {} ); -------------------------------------------------------------------------------- /less/main.less: -------------------------------------------------------------------------------- 1 | /** 2 | * Layout Styles 3 | */ 4 | 5 | @import (reference) "_mixins.less"; 6 | @import "_loading.less"; 7 | 8 | // Colors 9 | @danger: #d9534f; 10 | @success: #5cb85c; 11 | @warning: rgb(247, 160, 61); 12 | @info: #7FDBFF; 13 | @primary: #0074D9; 14 | @default: #AAAAAA; 15 | 16 | // Cloaking 17 | [ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, .x-ng-cloak { 18 | display: none !important; 19 | } -------------------------------------------------------------------------------- /js/missing.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 404 / Page Not Found 3 | */ 4 | 5 | // Route 6 | mainApp.config( function( $stateProvider, WP ) { 7 | $stateProvider.state( { 8 | name: 'missing', 9 | url: '/not-found/', 10 | templateUrl: WP.plugin_url + '/views/missing.html', 11 | controller: 'missingCtrl' 12 | } ); 13 | } ); 14 | 15 | // Controller 16 | mainApp.controller( 'missingCtrl', function( $scope ) { 17 | $scope.currentUrl = window.location.href; 18 | } ); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wordpress-angular-plugin", 3 | "version": "0.0.1", 4 | "description": "Build process for Angular.js and Bootstrap LESS", 5 | "main": "build.js", 6 | "repository": "https://github.com/Kevinlearynet/wordpress-angular-plugin", 7 | "scripts": { 8 | "build-latest": "git pull; npm update; gulp build;" 9 | }, 10 | "author": "kevinleary.net", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "gulp": "^3.9.1", 14 | "gulp-autoprefixer": "^3.1.1", 15 | "gulp-clean-css": "^3.0.4", 16 | "gulp-fs-cache": "^0.1.0", 17 | "gulp-include": "^2.3.1", 18 | "gulp-less": "^3.3.0", 19 | "gulp-ng-annotate": "^2.0.0", 20 | "gulp-rename": "^1.2.2", 21 | "gulp-sourcemaps": "^2.5.1", 22 | "gulp-uglify": "^2.1.2", 23 | "gulp-util": "^3.0.8", 24 | "pump": "^1.0.2" 25 | } 26 | } -------------------------------------------------------------------------------- /views/home.html: -------------------------------------------------------------------------------- 1 |
2 |

WordPress Angular Tutorial Demo

3 |

This plugin is powered by a WordPress plugin providing custom API's and routes.

4 | 5 |
6 | 7 |

More Information

8 | 14 | 15 |

Uses

16 | 24 |
-------------------------------------------------------------------------------- /less/_loading.less: -------------------------------------------------------------------------------- 1 | /** 2 | * CSS3 Loading Animation 3 | */ 4 | @size: 50px; 5 | 6 | .loading( @size: "50px" ) { 7 | @initial: ( @size * 0.75 ); 8 | position: relative; 9 | height: @size; 10 | width: @size; 11 | clip: rect( 0, @size, @size, 0 ); 12 | animation: animateLoader 2s linear infinite; 13 | display: inline-block; 14 | 15 | &:after { 16 | content: ''; 17 | .absolute(0,0,0,0); 18 | height: @size; 19 | width: @size; 20 | clip: rect( 0, @size, @size, @initial ); 21 | border-radius: 50%; 22 | box-shadow: inset @success 0 0 0 2px; 23 | animation: animateLoader2 2s linear infinite; 24 | } 25 | } 26 | 27 | .keyframes( animateLoader, { 28 | 0% { 29 | .rotate(0deg); 30 | } 31 | 32 | 100% { 33 | .rotate(360deg); 34 | } 35 | }); 36 | 37 | .keyframes( animateLoader2, { 38 | 0% { 39 | .rotate(-180deg); 40 | } 41 | 42 | 100% { 43 | .rotate(180deg); 44 | } 45 | }); 46 | 47 | .loading-img { 48 | .loading( 20px ); 49 | } 50 | 51 | .text-loading { 52 | line-height: 26px; 53 | } 54 | 55 | .text-loading .loading-img { 56 | vertical-align: -3px; 57 | margin-right: 5px; 58 | } -------------------------------------------------------------------------------- /js/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Angular.js App: mainApp 3 | */ 4 | 5 | // =require ../vendor/lodash.js 6 | // =require ../vendor/angular.js 7 | // =require ../vendor/angular-resource.js 8 | // =require ../vendor/angular-sanitize.js 9 | // =require ../vendor/angular-animate.js 10 | // =require ../vendor/angular-ui-router.js 11 | 12 | // App module 13 | var mainApp = angular.module( "mainApp", [ 14 | 'ng', 15 | 'ngResource', 16 | 'ngSanitize', 17 | 'ui.router', 18 | 'ngAnimate' 19 | ] ); 20 | 21 | // Third-party support 22 | mainApp.constant( '_', window._ ); 23 | mainApp.constant( 'WP', window.WP ); 24 | 25 | 26 | // Routing 27 | mainApp.config( function( $urlRouterProvider, $locationProvider ) { 28 | $locationProvider.html5Mode( true ); 29 | $urlRouterProvider.otherwise( '/not-found/' ); 30 | } ); 31 | 32 | // Boot 33 | mainApp.run( function( $rootScope, _ ) { 34 | 35 | // Log routing errors 36 | $rootScope.$on( "$stateChangeError", console.error.bind( console, '$stateChangeError' ) ); 37 | 38 | // Global lodash 39 | $rootScope._ = window._; 40 | } ); 41 | 42 | /** 43 | * Individual Routes/Views/Controllers 44 | */ 45 | 46 | // =require home.js 47 | // =require weather.js 48 | // =require missing.js -------------------------------------------------------------------------------- /views/weather.html: -------------------------------------------------------------------------------- 1 |
2 |

Local Weather Forecast

3 |

Lookup an accurate weather forecast in your area using the Geolocation API and the public National Weather Service API.

4 | 5 |
6 | 7 |
8 | Find My Forecast 9 |

Clicking the following button will prompt you for our location, we need it in order to find the weather in your area.

10 |
11 | 12 |

Loading…

13 | 14 |
15 |
16 | 19 |
20 |

{{ period.name }}

21 |
{{ period.shortForecast }}, {{ period.temperature }}°{{ period.temperatureUnit }}
22 |

{{ period.detailedForecast }}

23 |
24 |
25 |
26 |
-------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WordPress Angular.js Plugin Demo 2 | 3 | Example setup of using Angular.js inside of a WordPress plugin to create a stand-alone, API powered, Angular.js single page app or micro site. The demo app includes the following concepts: 4 | 5 | 1. Setup HTML5 pushState routing 6 | 2. Create custom API endpoints that are consumed by Angular's `$resource` service 7 | 3. Build LESS, JS and dynamic HTML with a gulp build 8 | 4. Auto-version CSS and JS inside of a dynamic HTML template (using Gulp) 9 | 5. How to handle it all inside of an isolated WordPress plugin 10 | 11 | ## Related 12 | 13 | * [Tutorial](https://www.kevinleary.net/angularjs-wordpress-tutorial/) 14 | * [Live Demo](https://www.kevinleary.net/wordpress-angular-plugin/) 15 | 16 | ## Install 17 | 18 | Make sure you have `node`, `npm`, and `gulp` packages installed globally then: 19 | 20 | 1. Clone the repo to your `/wp-content/plugins/` directory 21 | 2. Install Node packages for the build process: `npm install` 22 | 3. Run the Gulp build: `gulp build` 23 | 4. Activate the **WordPress Angular Plugin Demo** plugin within WordPress 24 | 25 | ## Build 26 | 27 | Gulp is used to compile, concatenate, and minify LESS and JS for the plugin. 28 | 29 | ### Tasks 30 | 31 | * `gulp` - Watch tasks that will compile when JS/LESS files change in real-time 32 | * `gulp build` - Compiles all JS/CSS then exits. -------------------------------------------------------------------------------- /js/weather.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Weather View 3 | */ 4 | 5 | // Route 6 | mainApp.config( function( $stateProvider, WP ) { 7 | $stateProvider.state( { 8 | name: 'weather', 9 | url: '/weather/', 10 | templateUrl: WP.plugin_url + '/views/weather.html', 11 | controller: 'weatherCtrl' 12 | } ); 13 | } ); 14 | 15 | // API Connection 16 | mainApp.factory( 'Weather', [ '$resource', function( $resource ) { 17 | return $resource( '/api/weather/:position', {}, { 18 | get: { 19 | method: 'GET', 20 | cache: true 21 | } 22 | } ); 23 | } ] ); 24 | 25 | // Controller 26 | mainApp.controller( 'weatherCtrl', function( $scope, $q, Weather ) { 27 | var cacheKey = 'weather-position'; 28 | 29 | // Find user location 30 | var getUserLocation = function() { 31 | var deferred = $q.defer(); 32 | var ttl = 86400 * 1000; // 1 day 33 | 34 | navigator.geolocation.getCurrentPosition( function( position ) { 35 | deferred.resolve( position ); 36 | }, function( error ) { 37 | deferred.reject( error ); 38 | }, { 39 | maximumAge: ttl, 40 | enableHighAccuracy: false 41 | } ); 42 | 43 | return deferred.promise; 44 | }; 45 | 46 | // Request forecast 47 | $scope.lookupWeather = function() { 48 | 49 | // Serve from cache 50 | var cache = sessionStorage.getItem( cacheKey ); 51 | if ( cache ) { 52 | $scope.forecast = JSON.parse( cache ); 53 | return; 54 | } 55 | 56 | // Find current geolocation 57 | $scope.loading = true; 58 | 59 | getUserLocation().then( function( position ) { 60 | Weather.get( { 61 | position: position.coords.latitude + ':' + position.coords.longitude 62 | } ).$promise.then( function( result ) { 63 | sessionStorage.setItem( cacheKey, JSON.stringify( result.data ) ); 64 | $scope.forecast = result.data; 65 | $scope.loading = false; 66 | } ); 67 | } ).catch( function( err ) { 68 | console.warn( err ); 69 | } ); 70 | }; 71 | } ); -------------------------------------------------------------------------------- /views/index.php: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | <?php echo $page_title; ?> 11 | 12 | 13 | 14 | 15 | 16 | 17 | 27 | 28 |
29 |
30 | 31 |
32 |
33 | 34 | 39 | 40 | 41 | 49 | 50 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Build Process for LESS + JS 5 | */ 6 | process.chdir( __dirname ); 7 | const gulp = require( 'gulp' ); 8 | const include = require( 'gulp-include' ); 9 | const less = require( 'gulp-less' ); 10 | const path = require( 'path' ); 11 | const uglify = require( 'gulp-uglify' ); 12 | const fsCache = require( 'gulp-fs-cache' ); 13 | const pump = require( 'pump' ); 14 | const ngAnnotate = require( 'gulp-ng-annotate' ); 15 | const autoprefixer = require( 'gulp-autoprefixer' ); 16 | const cleanCSS = require( 'gulp-clean-css' ); 17 | const rename = require( 'gulp-rename' ); 18 | const sourcemaps = require( 'gulp-sourcemaps' ); 19 | const fs = require( 'fs' ); 20 | const util = require( 'gulp-util' ); 21 | 22 | // Build tasks 23 | gulp.task( 'less', compileLESS ); 24 | gulp.task( 'js', compileJS ); 25 | gulp.task( 'default', watch ); 26 | gulp.task( 'build', [ 'less', 'js' ] ); 27 | 28 | // Error handler 29 | function next( error ) { 30 | if ( !error ) return; 31 | console.log( error ); 32 | } 33 | 34 | // Compile JavaScript 35 | function compileJS() { 36 | var jsCache = fsCache( '.gulp-cache/js' ); 37 | 38 | return pump( [ 39 | sourcemaps.init(), 40 | gulp.src( 'js/main.js' ), 41 | include( { 42 | hardFail: true, 43 | extensions: 'js' 44 | } ), 45 | ngAnnotate(), 46 | jsCache, 47 | uglify(), 48 | jsCache.restore, 49 | sourcemaps.write( './dist/maps' ), 50 | gulp.dest( './dist/js' ) 51 | ], next ); 52 | } 53 | 54 | // Compile LESS 55 | function compileLESS() { 56 | return pump( [ 57 | sourcemaps.init(), 58 | gulp.src( 'less/main.less' ), 59 | less( { 60 | paths: [ path.join( __dirname, 'less' ) ] 61 | } ), 62 | autoprefixer( { 63 | browsers: [ 'last 2 versions' ], 64 | cascade: false 65 | } ), 66 | cleanCSS( { 67 | compatibility: 'ie9' 68 | } ), 69 | sourcemaps.write( './dist/maps' ), 70 | gulp.dest( './dist/css' ) 71 | ], next ); 72 | } 73 | 74 | // Watch files and run tasks if they change 75 | function watch() { 76 | gulp.watch( [ 'less/*.less', 'js/**/*.less' ], [ 'less' ] ); 77 | gulp.watch( 'js/**/*.js', [ 'js' ] ); 78 | } -------------------------------------------------------------------------------- /wordpress-angular-plugin.php: -------------------------------------------------------------------------------- 1 | plugin_dir = plugin_dir_path( __FILE__ ); 27 | $this->plugin_url = plugins_url( '/', __FILE__ ); 28 | $this->versions = array(); 29 | 30 | // Routing 31 | $this->api_route = '^api/weather/(.*)/?'; // Matches /api/weather/{position} 32 | $this->base_href = '/' . basename( dirname( __FILE__ ) ) . '/'; // Matches /wordpress-angular-plugin/ 33 | add_filter( 'do_parse_request', array( $this, 'intercept_wp_router' ), 1, 3 ); 34 | add_filter( 'rewrite_rules_array', array( $this, 'rewrite_rules' ) ); 35 | add_filter( 'query_vars', array( $this, 'query_vars' ) ); 36 | add_action( 'wp_loaded', array( $this, 'flush_rewrites' ) ); 37 | add_action( 'parse_request', array( $this, 'weather_api' ), 1, 3 ); 38 | } 39 | 40 | 41 | /** 42 | * API Route Handler 43 | */ 44 | public function weather_api( $wp ) { 45 | 46 | // Weather API 47 | if ( $wp->matched_rule !== $this->api_route ) 48 | return; 49 | 50 | // Validate params 51 | if ( empty( $wp->query_vars['api_position'] ) ) { 52 | return wp_send_json_error( 'Missing required position parameters.' ); 53 | } 54 | 55 | // Lookup weather forecast 56 | $position = esc_attr( $wp->query_vars['api_position'] ); 57 | $coords = explode( ':', $position ); 58 | $lat = round( floatval( $coords[0] ), 4 ); 59 | $long = round( floatval( $coords[1] ), 4 ); 60 | $url = "https://api.weather.gov/points/$lat,$long/forecast"; 61 | $response = wp_remote_get( $url ); 62 | $status = wp_remote_retrieve_response_code( $response ); 63 | $body = wp_remote_retrieve_body( $response ); 64 | $body = json_decode( $body, true ); 65 | 66 | // Errors 67 | if ( $status !== 200 ) 68 | wp_send_json_error( $body ); 69 | 70 | // Cache control headers, these will cache the 71 | // users local weather forecast in the browser 72 | // for 1 day to reduce API requests. 73 | $ttl = DAY_IN_SECONDS; 74 | header( "Cache-Control: public, max-age=$ttl" ); 75 | header( "Last-Modified: $last_modified" ); 76 | 77 | // Success response 78 | wp_send_json_success( $body ); 79 | } 80 | 81 | 82 | // flush_rules() if our rules are not yet included 83 | public function flush_rewrites() { 84 | $rules = get_option( 'rewrite_rules' ); 85 | 86 | if ( ! isset( $rules[ $this->api_route ] ) ) { 87 | global $wp_rewrite; 88 | $wp_rewrite->flush_rules(); 89 | } 90 | } 91 | 92 | // Add rule for /api/weather/{position} 93 | public function rewrite_rules( $rules ) { 94 | $rules[ $this->api_route ] = 'index.php?api_position=$matches[1]'; 95 | return $rules; 96 | } 97 | 98 | // Adding the id var so that WP recognizes it 99 | public function query_vars( $vars ) { 100 | array_push( $vars, 'api_position' ); 101 | return $vars; 102 | } 103 | 104 | 105 | /** 106 | * Auto-version Assets 107 | */ 108 | public function auto_version_file( $path_to_file ) { 109 | $file = $this->plugin_dir . $path_to_file; 110 | if ( ! file_exists( $file ) ) return false; 111 | 112 | $mtime = filemtime( $file ); 113 | $url = $this->plugin_url . $path_to_file . '?v=' . $mtime; 114 | 115 | // Store for Last-Modified headers 116 | array_push( $this->versions, $mtime ); 117 | 118 | return $url; 119 | } 120 | 121 | 122 | /** 123 | * Intercept WP Router 124 | * 125 | * Intercept WordPress rewrites and serve a 126 | * static HTML page for our angular.js app. 127 | */ 128 | public function intercept_wp_router( $continue, WP $wp, $extra_query_vars ) { 129 | 130 | // Conditions for url path 131 | $url_match = ( substr( $_SERVER['REQUEST_URI'], 0, strlen( $this->base_href ) ) === $this->base_href ); 132 | if ( ! $url_match ) 133 | return $continue; 134 | 135 | // Vars for index view 136 | $main_js = $this->auto_version_file( 'dist/js/main.js' ); 137 | $main_css = $this->auto_version_file( 'dist/css/main.css' ); 138 | $plugin_url = $this->plugin_url; 139 | $base_href = $this->base_href; 140 | $page_title = 'WordPress Angular.js Plugin Demo App | kevinleary.net'; 141 | 142 | // Browser caching for our main template 143 | $ttl = DAY_IN_SECONDS; 144 | header( "Cache-Control: public, max-age=$ttl" ); 145 | 146 | // Load index view 147 | include_once( $this->plugin_dir . 'views/index.php' ); 148 | exit; 149 | } 150 | 151 | } // class ngApp 152 | 153 | new ngApp(); -------------------------------------------------------------------------------- /less/_mixins.less: -------------------------------------------------------------------------------- 1 | /*------------------------------------------------------------------------------ 2 | Mixins 3 | ------------------------------------------------------------------------------*/ 4 | 5 | // Variables 6 | @disabled: #eceeef; 7 | @gray-light: #999; 8 | @gray-lighter: #ccc; 9 | @color-gray-border: rgba(0, 0, 0, 0.13); 10 | @color-dark-bg: #373a3c; 11 | @success: #5cb85c; 12 | @warning: #f0ad4e; 13 | @danger: #d9534f; 14 | @muted-bg: #f5f5f5; 15 | 16 | .rotate( @deg ) { 17 | -webkit-transform: rotate(@deg); 18 | -moz-transform: rotate(@deg); 19 | -ms-transform: rotate(@deg); 20 | -o-transform: rotate(@deg); 21 | transform: rotate(@deg); 22 | } 23 | 24 | .absolute( @top: auto, @right: auto, @bottom: auto, @left: auto ) { 25 | position: absolute; 26 | top: @top; 27 | right: @right; 28 | bottom: @bottom; 29 | left: @left; 30 | } 31 | 32 | .keyframes(@name; @arguments) { 33 | @-moz-keyframes @name { @arguments(); } 34 | @-webkit-keyframes @name { @arguments(); } 35 | @keyframes @name { @arguments(); } 36 | } 37 | 38 | .animation(@arguments) { 39 | -webkit-animation: @arguments; 40 | -moz-animation: @arguments; 41 | -ms-animation: @arguments; 42 | -o-animation: @arguments; 43 | animation: @arguments; 44 | } 45 | 46 | .background-size( @props: cover ) { 47 | -webkit-background-size: @props; 48 | -moz-background-size: @props; 49 | -o-background-size: @props; 50 | background-size: @props; 51 | } 52 | 53 | .gradient( @startColor: #eee, @endColor: white) { 54 | background-color: @startColor; 55 | background: -webkit-gradient( linear, top, bottom, from(@startColor) 0%, to(@endColor) 15% ); 56 | background: -webkit-linear-gradient( top, @startColor 0%, @endColor 15% ); 57 | background: -moz-linear-gradient( top, @startColor 0%, @endColor 15% ); 58 | background: -ms-linear-gradient( top, @startColor 0%, @endColor 15% ); 59 | background: -o-linear-gradient( top, @startColor 0%, @endColor 15% ); 60 | } 61 | 62 | .horizontal-gradient (@startColor: #eee, @endColor: white) { 63 | background-color: @startColor; 64 | background-image: -webkit-gradient(linear, left top, right top, from(@startColor), to(@endColor)); 65 | background-image: -webkit-linear-gradient(left, @startColor, @endColor); 66 | background-image: -moz-linear-gradient(left, @startColor, @endColor); 67 | background-image: -ms-linear-gradient(left, @startColor, @endColor); 68 | background-image: -o-linear-gradient(left, @startColor, @endColor); 69 | } 70 | 71 | .absolute( @top: 0, @right: 0, @bottom: 0, @left: 0, @zIndex: 1 ) { 72 | position: absolute; 73 | top: @top; 74 | right: @right; 75 | bottom: @bottom; 76 | left: @left; 77 | z-index: @zIndex; 78 | } 79 | 80 | .max-media(@maxWidth; @rules) { 81 | @media only screen and (max-width: @maxWidth) { 82 | @rules(); 83 | } 84 | } 85 | 86 | .min-media(@minWidth; @rules) { 87 | @media only screen and (min-width: @minWidth) { 88 | @rules(); 89 | } 90 | } 91 | 92 | .box-sizing( @args ) { 93 | -webkit-box-sizing: @args; 94 | -moz-box-sizing: @args; 95 | box-sizing: @args; 96 | } 97 | 98 | .box-shadow( @args ) { 99 | -webkit-box-shadow: @args; 100 | -moz-box-shadow: @args; 101 | box-shadow: @args; 102 | } 103 | 104 | .appearance( @args ) { 105 | -webkit-appearance: @args; 106 | -moz-appearance: @args; 107 | -ms-appearance: @args; 108 | -o-appearance: @args; 109 | appearance: @args; 110 | } 111 | 112 | .border-radius( @radius ) { 113 | border-radius: @radius; 114 | -webkit-border-radius: @radius; 115 | -moz-border-radius: @radius; 116 | } 117 | 118 | .transition( @props ) { 119 | -webkit-transition: @props; 120 | -moz-transition: @props; 121 | -o-transition: @props; 122 | transition: @props; 123 | } 124 | 125 | .transform( @props ) { 126 | -webkit-transform: @props; 127 | -moz-transform: @props; 128 | -ms-transform: @props; 129 | -o-transform: @props; 130 | transform: @props; 131 | } 132 | 133 | .background-size( @props ) { 134 | -webkit-background-size: @props; 135 | -moz-background-size: @props; 136 | -o-background-size: @props; 137 | background-size: @props; 138 | } 139 | 140 | .clearfix() { 141 | &:after { 142 | visibility: hidden; 143 | display: block; 144 | font-size: 0; 145 | content: " "; 146 | clear: both; 147 | height: 0; 148 | } 149 | } 150 | 151 | .include-custom-font( @label, @family, @weight, @style ) { 152 | @font-face { 153 | font-family: @label; 154 | src: url( "/_/fonts/@{family}.eot" ); 155 | src: url( "/_/fonts/@{family}.eot?#iefix" ) format( "embedded-opentype" ), 156 | url( "/_/fonts/@{family}.woff2" ) format( "woff2" ), 157 | url( "/_/fonts/@{family}.woff" ) format( "woff" ), 158 | url( "/_/fonts/@{family}.ttf" ) format( "truetype" ), 159 | url( "/_/fonts/@{family}.svg#@{family}" ) format( "svg" ); 160 | font-weight: @weight; 161 | font-style: @style; 162 | } 163 | } 164 | 165 | // -------------------------------------------------- 166 | // Flexbox LESS mixins 167 | // The spec: http://www.w3.org/TR/css3-flexbox 168 | // -------------------------------------------------- 169 | 170 | // Flexbox display 171 | // flex or inline-flex 172 | .flex-display(@display: flex) { 173 | display: ~"-webkit-@{display}"; 174 | display: ~"-moz-@{display}"; 175 | display: ~"-ms-@{display}box"; // IE10 uses -ms-flexbox 176 | display: ~"-ms-@{display}"; // IE11 177 | display: @display; 178 | } 179 | 180 | // The 'flex' shorthand 181 | // - applies to: flex items 182 | // , initial, auto, or none 183 | .flex(@columns: initial) { 184 | -webkit-flex: @columns; 185 | -moz-flex: @columns; 186 | -ms-flex: @columns; 187 | flex: @columns; 188 | } 189 | 190 | // Flex Flow Direction 191 | // - applies to: flex containers 192 | // row | row-reverse | column | column-reverse 193 | .flex-direction(@direction: row) { 194 | -webkit-flex-direction: @direction; 195 | -moz-flex-direction: @direction; 196 | -ms-flex-direction: @direction; 197 | flex-direction: @direction; 198 | } 199 | 200 | // Flex Line Wrapping 201 | // - applies to: flex containers 202 | // nowrap | wrap | wrap-reverse 203 | .flex-wrap(@wrap: nowrap) { 204 | -webkit-flex-wrap: @wrap; 205 | -moz-flex-wrap: @wrap; 206 | -ms-flex-wrap: @wrap; 207 | flex-wrap: @wrap; 208 | } 209 | 210 | // Flex Direction and Wrap 211 | // - applies to: flex containers 212 | // || 213 | .flex-flow(@flow) { 214 | -webkit-flex-flow: @flow; 215 | -moz-flex-flow: @flow; 216 | -ms-flex-flow: @flow; 217 | flex-flow: @flow; 218 | } 219 | 220 | // Display Order 221 | // - applies to: flex items 222 | // 223 | .flex-order(@order: 0) { 224 | -webkit-order: @order; 225 | -moz-order: @order; 226 | -ms-order: @order; 227 | order: @order; 228 | } 229 | 230 | // Flex grow factor 231 | // - applies to: flex items 232 | // 233 | .flex-grow(@grow: 0) { 234 | -webkit-flex-grow: @grow; 235 | -moz-flex-grow: @grow; 236 | -ms-flex-grow: @grow; 237 | flex-grow: @grow; 238 | } 239 | 240 | // Flex shrink 241 | // - applies to: flex item shrink factor 242 | // 243 | .flex-shrink(@shrink: 1) { 244 | -webkit-flex-shrink: @shrink; 245 | -moz-flex-shrink: @shrink; 246 | -ms-flex-shrink: @shrink; 247 | flex-shrink: @shrink; 248 | } 249 | 250 | // Flex basis 251 | // - the initial main size of the flex item 252 | // - applies to: flex itemsnitial main size of the flex item 253 | // 254 | .flex-basis(@width: auto) { 255 | -webkit-flex-basis: @width; 256 | -moz-flex-basis: @width; 257 | -ms-flex-basis: @width; 258 | flex-basis: @width; 259 | } 260 | 261 | // Axis Alignment 262 | // - applies to: flex containers 263 | // flex-start | flex-end | center | space-between | space-around 264 | .justify-content(@justify: flex-start) { 265 | -webkit-justify-content: @justify; 266 | -moz-justify-content: @justify; 267 | -ms-justify-content: @justify; 268 | justify-content: @justify; 269 | } 270 | 271 | // Packing Flex Lines 272 | // - applies to: multi-line flex containers 273 | // flex-start | flex-end | center | space-between | space-around | stretch 274 | .align-content(@align: stretch) { 275 | -webkit-align-content: @align; 276 | -moz-align-content: @align; 277 | -ms-align-content: @align; 278 | align-content: @align; 279 | } 280 | 281 | // Cross-axis Alignment 282 | // - applies to: flex containers 283 | // flex-start | flex-end | center | baseline | stretch 284 | .align-items(@align: stretch) { 285 | -webkit-align-items: @align; 286 | -moz-align-items: @align; 287 | -ms-align-items: @align; 288 | align-items: @align; 289 | } 290 | 291 | // Cross-axis Alignment 292 | // - applies to: flex items 293 | // auto | flex-start | flex-end | center | baseline | stretch 294 | .align-self(@align: auto) { 295 | -webkit-align-self: @align; 296 | -moz-align-self: @align; 297 | -ms-align-self: @align; 298 | align-self: @align; 299 | } -------------------------------------------------------------------------------- /tutorial.md: -------------------------------------------------------------------------------- 1 | The tutorial will show you how to setup and work with Angular.js inside of a WordPress plugin to create a stand-alone, API powered, Angular.js single page app or microsite that is available at a defined path of a WordPress website. 2 | 3 |

Live Demo   GitHub Source

4 | 5 | By the end of this tutorial you should grasp a few key things about working with Angular.js inside of WordPress. Using a self-installed WordPress install as a backend API service for serving HTTP requests to a front-end Angular.js client is a powerful combination. If you're an eager beaver you can dive right into the [sample plugin on GitHub](https://github.com/Kevinlearynet/wordpress-angular-plugin) which provides the source for the working demo. This example app should demonstrate the following concepts I commonly come across when building Angular apps in WordPress: 6 | 7 | 1. How to work with HTML5 pushState routing 8 | 1. Creating custom API endpoints that are consumed by Angular's `$resource` service 9 | 1. Work with gulp to compile your front-end's LESS, JS and more with a build process 10 | 1. Auto-version your CSS and JS includes 11 | 1. Setup browser cache rules for `/api/**` routes to reduce requests per second (when it makes sense to) 12 | 1. How to handle it all inside of an isolated WordPress plugin 13 | 14 | **If there's a common scenario I didn't mention that you would like to recommend please let me know in the [blog post comments](https://www.kevinleary.net/angularjs-wordpress-tutorial/#respond).** 15 | 16 | ## Table of Contents 17 | 18 | 1. [WordPress Plugin](#wordpress-plugin) 19 | 1. Why a plugin and not a theme? 20 | 1. Server-side routing 21 | 1. Auto-versioning CSS/JS 22 | 1. API Requests to WordPress 23 | 1. [Angular Front-end](#angular-frontend) 24 | 1. Index template 25 | 1. Front-end routing 26 | 1. Concatenating JS includes 27 | 1. HTML5 `pushState` routing 28 | 1. [Gulp Build](#gulp-build) 29 | 1. Watch & Build CSS/JS 30 | 1. JS Compile Tasks 31 | 1. LESS Compile Tasks 32 | 33 | ## WordPress Plugin 34 | 35 | To begin we we'll start by creating a fresh WordPress plugin. I'm assuming you know how to do this already, but if not then I recommend reading the [detailed plugin guidelines](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) provided on WordPress.org. This plugin will serve as a backend to our angular app, and will handling the following logic for us: 36 | 37 | 1. Add routing rules to WordPress for serving custom API endpoints 38 | 2. Add routing rules to WordPress to load our base app index template at `/wordpress-angular-plugin/**`. The trailing wildcard is critical for supporting HTML5 pushState routing within our Angular app. 39 | 3. Process and cache HTTP API requests to third-party providers on the server-side (i.e. the US National Weather Service) 40 | 41 | ### Why a plugin and not a theme? 42 | 43 | There are a few key benefits to building both the backend and front-end inside of a single WordPress plugin: 44 | 45 | 1. **Simplicity:** I don't have to support two separate servers, one for a WordPress backend API and another for serving the Angular.js app's HTML. With this approach I can easily do both from one environment. 46 | 1. **Access to WP:** I've found that it's useful to have easy server-side access to WordPress when working with it as a backend. A few scenarios include processing Gravity Forms submissions, passing values from server-side to client-side with `wp_localize_script()` when users are logged in, and various other things. 47 | 1. **Portability** By isolating everything into a WordPress plugin we can easily move our entire app from site to site, enabling and disabling on demand. 48 | 49 | All of the logic described in this tutorial could be used within a WordPress theme as well, the same concepts apply. 50 | 51 | ### Server-side routing 52 | 53 | Our WordPress plugin defines the URL where our Angular app will load based on the path of the plugin directory. The `intercept_wp_router()` method is applied to the `do_parse_request` filter to handle this: 54 | 55 | ~~~.language-php 56 | /** 57 | * Intercept WP Router 58 | * 59 | * Intercept WordPress rewrites and serve a 60 | * static HTML page for our angular.js app. 61 | */ 62 | public function intercept_wp_router( $continue, WP $wp, $extra_query_vars ) { 63 | 64 | // Conditions for url path 65 | $url_match = ( substr( $_SERVER['REQUEST_URI'], 0, strlen( $this->base_href ) ) === $this->base_href ); 66 | if ( ! $url_match ) 67 | return $continue; 68 | 69 | // Vars for index view 70 | $main_js = $this->auto_version_file( 'dist/js/main.js' ); 71 | $main_css = $this->auto_version_file( 'dist/css/main.css' ); 72 | $plugin_url = $this->plugin_url; 73 | $base_href = $this->base_href; 74 | $page_title = 'WordPress Angular.js Plugin Demo App | kevinleary.net'; 75 | 76 | // Browser caching for our main template 77 | $ttl = DAY_IN_SECONDS; 78 | header( "Cache-Control: public, max-age=$ttl" ); 79 | 80 | // Load index view 81 | include_once( $this->plugin_dir . 'views/index.php' ); 82 | exit; 83 | } 84 | ~~~ 85 | 86 | If you want to change the base URL for your app to something custom you'll need to change the value of the public variable `base_href`. This is set in the `__constuct()` method of the `ngApp` Class. That's a mouthful, but basically you would find and modify this line within the plugin: 87 | 88 | ~~~.language-php 89 | dirname( __FILE__ ) 90 | ~~~ 91 | 92 | In the case of this tutorial, our main plugin file is `wordpress-angular-plugin/wordpress-angular-plugin.php` so the Angular app will load at `/wordpress-angular-plugin/` out of the box. **You can change this whatever you like in the plugin to customize the URL.** 93 | 94 | Once you load up `https://www.yoursite.com/wordpress-angular-plugin/` you should see the same Angular app demo currently available at: 95 | 96 | [kevinleary.net/wordpress-angular-plugin/](https://www.kevinleary.net/wordpress-angular-plugin/) 97 | 98 | ### Auto-versioning CSS/JS 99 | 100 | Google Chrome and other browsers will cache our *.css and *.js for an indefinite period of time. If we make changes to our Angular app's code, or our LESS stylesheet, browsers won't know the file has changed and could serve the old, previously cached version of the file to repeat visitors. For this reason, it's very important that we add version strings to static resources, or in our case the `/dist/js/main.js` and `/dist/css/main.css` files. **This is especially important for single page apps** because we are effectively loading EVERYTHING in the browser. 101 | 102 | Luckily, I've included a setup in this plugin that will handle this for you automatically. This is the only thing that PHP is actually used for in the `index.php` template. 103 | 104 | Here's the method that handles this for us: 105 | 106 | ~~~.language-php 107 | /** 108 | * Auto-version Assets 109 | */ 110 | public function auto_version_file( $path_to_file ) { 111 | $file = $this->plugin_dir . $path_to_file; 112 | if ( ! file_exists( $file ) ) return false; 113 | 114 | $mtime = filemtime( $file ); 115 | $url = $this->plugin_url . $path_to_file . '?v=' . $mtime; 116 | 117 | return $url; 118 | } 119 | ~~~ 120 | 121 | Using PHP's `filemtime()` function we check the modified time of the CSS and JS files, and then we add the timestamp returned to the end of each file as a "version string" like this: 122 | 123 | * `/dist/css/main.css?v=1497114238` 124 | * `/dist/js/main.js?v=1497114238` 125 | 126 | Now you'll always have up to date assets loading for your users! 127 | 128 | ### API Requests to WordPress 129 | 130 | Now that we have the basic structure for an Angular app setup within a WordPress plugin let's look at how to make requests from client (Angular) to server (WordPress) by defining a few custom HTTP API routes. For demo purposes I've wired together a backend API that will: 131 | 132 | 1. Handle incoming requests to `/api/weather/{latitude:longitude}/` 133 | 2. Lookup a weather forecast for the provided lat/long using the National Weather Service API 134 | 3. Return the response body as JSON back to the client 135 | 136 | In a real-world scenario we could just do this inside of Angular entirely, but it serves as a good example to cover common situations where: 137 | 138 | 1. You want to make requests to a secured API without exposing keys on the client-side 139 | 2. You want to cache the results of remote API responses locally to server faster responses, and avoid overage costs for API's where you pay by the request 140 | 3. You want to serve cached results of remote API requests that have been performed by a worker process (_bonus points for performance_) 141 | 142 | In the context of our demo app, an API request is made to the weather API endpoint defined in our plugin to retreive the weather for a users location. This provides a basic demonstration of how to write a PHP backend service that processes input from our Angular app. 143 | 144 | **Important Note** 145 | 146 | I am deliberately NOT using the official [WP REST API](https://wordpress.org/plugins/json-rest-api/) here. I personally believe in building minimal systems that solve specific problems, I think it make a big difference in terms of maintenance, sustainability and security. This is entirely my own opinion, but I beleive it's better to build your own microservices like this rather than load in the entire WP JSON api for many circumstances. 147 | 148 | ## Angular App Front-end 149 | 150 | _The Angular.js app is currently using Angular 1. Most of the project I work on at the moment are still using Angular 1, only a few have made the switch to 2. Because some of this code is directly pulled from those projects I found it easier to work with Angular 1. **In the future I will update this to Angular 2 on another branch.**_ 151 | 152 | The Angular 1 app in this demo is very basic, but it does handle a few important things well: 153 | 154 | 1. Concatenates, minifies and proressively enhances LESS and JS using Gulp 155 | 1. Provides HTML5 pushState routing - NO hashbangs here (e.g. www.you.com/pathname/#/slug) 156 | 1. Handles 404's beneath our plugin's top level URL path 157 | 1. Route separation - Individual routes/views/controllers are broken down into separate files. When Angular projects get large and span multiple developers this is very helpful. 158 | 1. Uses the `$resource` service to interface with a custom HTTP API we'll define in WordPress 159 | 1. **Doesn't rely on the WordPress JSON API in anyway** 160 | 1. Provides access to the [Lodash](https://lodash.com/) as an Angular service and template helper 161 | 162 | ### Index template 163 | 164 | Our Angular app is served from a single file: `./views/index.php`. This is where we define our `[ng-app]`, the structure of our HTML doc, and the `` directive provided by [ui-router](https://ui-router.github.io/). This tag will specify where every view inside of our Angular app will load when the URL changes or initially loads. 165 | 166 | Using *.php and not *.html allows us to easily pass values from WordPress into Angular by adding them to an inline JSON object before we load `main.js`. This is a similar approadch to using the `wp_localize_script()` function in WordPress to pass PHP values from a plugin or theme into JS. 167 | 168 | ### Front-end Routing 169 | 170 | The Angular app is served from a single file within the plugin: `./views/index.php`. This is where we define our `[ng-app]`, the structure of our HTML doc, and the `` directive provided by [ui-router](https://ui-router.github.io/). This tag will specify where every view inside of our Angular app will load when the URL changes or initially loads **at or below the app path defined by our plugin.** 171 | 172 | This means that anything underneath this URL is handled by Angular.js routing and the [ui-router](https://ui-router.github.io/) module. We've specifically setup the way we route to and load in our `/views/index.php` file to support this structure. This will handle the WordPress angular routing in a seamless way so that: 173 | 174 | 1. If you visit the URL of an Angular defined route directly it will load in the expected route/view/controller configuration 175 | 2. When you browse from view-to-view within the app fully qualified URL's will be loaded into the browser using the HTML5 pushState API 176 | 177 | It is only possible to do this if **EVERYTHING** underneath our `/wordpress-angular-plugin/` page is handled by Angular and UI router. 178 | 179 | UI-Router defines routes for everything served by Angular undereath the `/wordpress-angular-plugin/` directory. Here's what a typical route definition looks like. 180 | 181 | ~~~.language-javascript 182 | /** 183 | * Home View 184 | */ 185 | 186 | // Route 187 | mainApp.config( function( $stateProvider, WP ) { 188 | $stateProvider.state( { 189 | name: 'home', 190 | url: '/', 191 | templateUrl: WP.plugin_url + '/views/home.html', 192 | controller: 'homeCtrl' 193 | } ); 194 | } ); 195 | 196 | // Controller 197 | mainApp.controller( 'homeCtrl', function( $scope ) {} ); 198 | ~~~ 199 | 200 | This is pulled right from the contents of the `/views/home.html` template. The `mainApp` is our main ng module defined in `/js/main.js` with `angular.module()`. 201 | 202 | ### Concatenating JS Includes 203 | 204 | When you look at the source of `/js/main.js` you'll see a number of lines that look like this: 205 | 206 | ~~~.language-javascript 207 | // =require ../vendor/angular-sanitize.js 208 | // =require ../vendor/angular-animate.js 209 | // =require ../vendor/angular-ui-router.js 210 | ~~~ 211 | 212 | These define external JS files that we want to include in our JS app. These are handled by gulp during the compile process with the help of the [gulp-include](https://www.npmjs.com/package/gulp-include) plugin. For now I find that this approach is straight forward and easier to work with than Webpack or CommonJS, but if you find that your `/dist/js/main.js` file is getting too large then it may be best to work with [Webpack](https://webpack.github.io/) instead. 213 | 214 | _More information on the build process can be found in the [compiling with gulp](#gulp-compiling) section._ 215 | 216 | ## Compiling with Gulp 217 | 218 | The plugin uses [Gulp](http://gulpjs.com/) to compile our CSS/JS and also prepares our HTML, with a few smart additions. 219 | 220 | ### Watch & Build CSS/JS 221 | 222 | I recommend that you use the watch process to build your CSS/JS on-demand as it changes. This provides a faster web development workflow. To start gulp in watch mode open a Terminal window and go to the directory where you've cloned this plugin. Enter the `gulp` command in your prompt and Gulp will begin watching for JS/CSS changes. 223 | 224 | If you've just cloned the repo you can build everything with the `gulp build` command. This will run through every process needed to everything we need to serve our micro app (CSS/JS/HTML). 225 | 226 | ### JS Compile Task 227 | 228 | The `js-main` gulp task uses a few gulp plugins to compile and prepare our JavaScript files for the app. This part of the turorial is entirely opinionated, it's a set of tasks that I commonly use so I've included them here. 229 | 230 | 1. Sourcemaps will be generated for easier debugging 231 | 1. The [gulp-include](https://www.npmjs.com/package/gulp-include) plugin will provide an interface similar to CodeKit, allowing you to concatenate multiple JS files into a single compiled file 232 | 1. The [ng-annotate](https://www.npmjs.com/package/ng-annotate) plugin adds and removes AngularJS dependency injection annotations. 233 | 1. Uglify handles minification to compress our source 234 | 1. Caching is added to help speed up Uglify minification 235 | 236 | ### LESS Compile Task 237 | 238 | The `less` task is pretty straight forward. It compiles LESS to CSS, with the following helpful additions: 239 | 240 | 1. Backwards support to automatically add browser prefixes with autoprefixer 241 | 2. Concatenation and minification handled by cleanCSS 242 | 243 | ## Conclusion 244 | 245 | The combination of an Angular.js front-end and a WordPress API backend provides a powerful framework for building all kinds of find things. Hopefully this tutorial gives you a few ideas about how work with the two technologies in your projects. I do myself all the time. **If you have any questions, comments or feedback please let me know in the comments below.** -------------------------------------------------------------------------------- /vendor/angular-sanitize.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license AngularJS v1.5.8 3 | * (c) 2010-2016 Google, Inc. http://angularjs.org 4 | * License: MIT 5 | */ 6 | (function(window, angular) {'use strict'; 7 | 8 | /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * 9 | * Any commits to this file should be reviewed with security in mind. * 10 | * Changes to this file can potentially create security vulnerabilities. * 11 | * An approval from 2 Core members with history of modifying * 12 | * this file is required. * 13 | * * 14 | * Does the change somehow allow for arbitrary javascript to be executed? * 15 | * Or allows for someone to change the prototype of built-in objects? * 16 | * Or gives undesired access to variables likes document or window? * 17 | * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ 18 | 19 | var $sanitizeMinErr = angular.$$minErr('$sanitize'); 20 | var bind; 21 | var extend; 22 | var forEach; 23 | var isDefined; 24 | var lowercase; 25 | var noop; 26 | var htmlParser; 27 | var htmlSanitizeWriter; 28 | 29 | /** 30 | * @ngdoc module 31 | * @name ngSanitize 32 | * @description 33 | * 34 | * # ngSanitize 35 | * 36 | * The `ngSanitize` module provides functionality to sanitize HTML. 37 | * 38 | * 39 | *
40 | * 41 | * See {@link ngSanitize.$sanitize `$sanitize`} for usage. 42 | */ 43 | 44 | /** 45 | * @ngdoc service 46 | * @name $sanitize 47 | * @kind function 48 | * 49 | * @description 50 | * Sanitizes an html string by stripping all potentially dangerous tokens. 51 | * 52 | * The input is sanitized by parsing the HTML into tokens. All safe tokens (from a whitelist) are 53 | * then serialized back to properly escaped html string. This means that no unsafe input can make 54 | * it into the returned string. 55 | * 56 | * The whitelist for URL sanitization of attribute values is configured using the functions 57 | * `aHrefSanitizationWhitelist` and `imgSrcSanitizationWhitelist` of {@link ng.$compileProvider 58 | * `$compileProvider`}. 59 | * 60 | * The input may also contain SVG markup if this is enabled via {@link $sanitizeProvider}. 61 | * 62 | * @param {string} html HTML input. 63 | * @returns {string} Sanitized HTML. 64 | * 65 | * @example 66 | 67 | 68 | 80 |
81 | Snippet: 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 |
DirectiveHowSourceRendered
ng-bind-htmlAutomatically uses $sanitize
<div ng-bind-html="snippet">
</div>
ng-bind-htmlBypass $sanitize by explicitly trusting the dangerous value 99 |
<div ng-bind-html="deliberatelyTrustDangerousSnippet()">
100 | </div>
101 |
ng-bindAutomatically escapes
<div ng-bind="snippet">
</div>
111 |
112 |
113 | 114 | it('should sanitize the html snippet by default', function() { 115 | expect(element(by.css('#bind-html-with-sanitize div')).getInnerHtml()). 116 | toBe('

an html\nclick here\nsnippet

'); 117 | }); 118 | 119 | it('should inline raw snippet if bound to a trusted value', function() { 120 | expect(element(by.css('#bind-html-with-trust div')).getInnerHtml()). 121 | toBe("

an html\n" + 122 | "click here\n" + 123 | "snippet

"); 124 | }); 125 | 126 | it('should escape snippet without any filter', function() { 127 | expect(element(by.css('#bind-default div')).getInnerHtml()). 128 | toBe("<p style=\"color:blue\">an html\n" + 129 | "<em onmouseover=\"this.textContent='PWN3D!'\">click here</em>\n" + 130 | "snippet</p>"); 131 | }); 132 | 133 | it('should update', function() { 134 | element(by.model('snippet')).clear(); 135 | element(by.model('snippet')).sendKeys('new text'); 136 | expect(element(by.css('#bind-html-with-sanitize div')).getInnerHtml()). 137 | toBe('new text'); 138 | expect(element(by.css('#bind-html-with-trust div')).getInnerHtml()).toBe( 139 | 'new text'); 140 | expect(element(by.css('#bind-default div')).getInnerHtml()).toBe( 141 | "new <b onclick=\"alert(1)\">text</b>"); 142 | }); 143 |
144 |
145 | */ 146 | 147 | 148 | /** 149 | * @ngdoc provider 150 | * @name $sanitizeProvider 151 | * 152 | * @description 153 | * Creates and configures {@link $sanitize} instance. 154 | */ 155 | function $SanitizeProvider() { 156 | var svgEnabled = false; 157 | 158 | this.$get = ['$$sanitizeUri', function($$sanitizeUri) { 159 | if (svgEnabled) { 160 | extend(validElements, svgElements); 161 | } 162 | return function(html) { 163 | var buf = []; 164 | htmlParser(html, htmlSanitizeWriter(buf, function(uri, isImage) { 165 | return !/^unsafe:/.test($$sanitizeUri(uri, isImage)); 166 | })); 167 | return buf.join(''); 168 | }; 169 | }]; 170 | 171 | 172 | /** 173 | * @ngdoc method 174 | * @name $sanitizeProvider#enableSvg 175 | * @kind function 176 | * 177 | * @description 178 | * Enables a subset of svg to be supported by the sanitizer. 179 | * 180 | *
181 | *

By enabling this setting without taking other precautions, you might expose your 182 | * application to click-hijacking attacks. In these attacks, sanitized svg elements could be positioned 183 | * outside of the containing element and be rendered over other elements on the page (e.g. a login 184 | * link). Such behavior can then result in phishing incidents.

185 | * 186 | *

To protect against these, explicitly setup `overflow: hidden` css rule for all potential svg 187 | * tags within the sanitized content:

188 | * 189 | *
190 | * 191 | *

192 |    *   .rootOfTheIncludedContent svg {
193 |    *     overflow: hidden !important;
194 |    *   }
195 |    *   
196 | *
197 | * 198 | * @param {boolean=} flag Enable or disable SVG support in the sanitizer. 199 | * @returns {boolean|ng.$sanitizeProvider} Returns the currently configured value if called 200 | * without an argument or self for chaining otherwise. 201 | */ 202 | this.enableSvg = function(enableSvg) { 203 | if (isDefined(enableSvg)) { 204 | svgEnabled = enableSvg; 205 | return this; 206 | } else { 207 | return svgEnabled; 208 | } 209 | }; 210 | 211 | ////////////////////////////////////////////////////////////////////////////////////////////////// 212 | // Private stuff 213 | ////////////////////////////////////////////////////////////////////////////////////////////////// 214 | 215 | bind = angular.bind; 216 | extend = angular.extend; 217 | forEach = angular.forEach; 218 | isDefined = angular.isDefined; 219 | lowercase = angular.lowercase; 220 | noop = angular.noop; 221 | 222 | htmlParser = htmlParserImpl; 223 | htmlSanitizeWriter = htmlSanitizeWriterImpl; 224 | 225 | // Regular Expressions for parsing tags and attributes 226 | var SURROGATE_PAIR_REGEXP = /[\uD800-\uDBFF][\uDC00-\uDFFF]/g, 227 | // Match everything outside of normal chars and " (quote character) 228 | NON_ALPHANUMERIC_REGEXP = /([^\#-~ |!])/g; 229 | 230 | 231 | // Good source of info about elements and attributes 232 | // http://dev.w3.org/html5/spec/Overview.html#semantics 233 | // http://simon.html5.org/html-elements 234 | 235 | // Safe Void Elements - HTML5 236 | // http://dev.w3.org/html5/spec/Overview.html#void-elements 237 | var voidElements = toMap("area,br,col,hr,img,wbr"); 238 | 239 | // Elements that you can, intentionally, leave open (and which close themselves) 240 | // http://dev.w3.org/html5/spec/Overview.html#optional-tags 241 | var optionalEndTagBlockElements = toMap("colgroup,dd,dt,li,p,tbody,td,tfoot,th,thead,tr"), 242 | optionalEndTagInlineElements = toMap("rp,rt"), 243 | optionalEndTagElements = extend({}, 244 | optionalEndTagInlineElements, 245 | optionalEndTagBlockElements); 246 | 247 | // Safe Block Elements - HTML5 248 | var blockElements = extend({}, optionalEndTagBlockElements, toMap("address,article," + 249 | "aside,blockquote,caption,center,del,dir,div,dl,figure,figcaption,footer,h1,h2,h3,h4,h5," + 250 | "h6,header,hgroup,hr,ins,map,menu,nav,ol,pre,section,table,ul")); 251 | 252 | // Inline Elements - HTML5 253 | var inlineElements = extend({}, optionalEndTagInlineElements, toMap("a,abbr,acronym,b," + 254 | "bdi,bdo,big,br,cite,code,del,dfn,em,font,i,img,ins,kbd,label,map,mark,q,ruby,rp,rt,s," + 255 | "samp,small,span,strike,strong,sub,sup,time,tt,u,var")); 256 | 257 | // SVG Elements 258 | // https://wiki.whatwg.org/wiki/Sanitization_rules#svg_Elements 259 | // Note: the elements animate,animateColor,animateMotion,animateTransform,set are intentionally omitted. 260 | // They can potentially allow for arbitrary javascript to be executed. See #11290 261 | var svgElements = toMap("circle,defs,desc,ellipse,font-face,font-face-name,font-face-src,g,glyph," + 262 | "hkern,image,linearGradient,line,marker,metadata,missing-glyph,mpath,path,polygon,polyline," + 263 | "radialGradient,rect,stop,svg,switch,text,title,tspan"); 264 | 265 | // Blocked Elements (will be stripped) 266 | var blockedElements = toMap("script,style"); 267 | 268 | var validElements = extend({}, 269 | voidElements, 270 | blockElements, 271 | inlineElements, 272 | optionalEndTagElements); 273 | 274 | //Attributes that have href and hence need to be sanitized 275 | var uriAttrs = toMap("background,cite,href,longdesc,src,xlink:href"); 276 | 277 | var htmlAttrs = toMap('abbr,align,alt,axis,bgcolor,border,cellpadding,cellspacing,class,clear,' + 278 | 'color,cols,colspan,compact,coords,dir,face,headers,height,hreflang,hspace,' + 279 | 'ismap,lang,language,nohref,nowrap,rel,rev,rows,rowspan,rules,' + 280 | 'scope,scrolling,shape,size,span,start,summary,tabindex,target,title,type,' + 281 | 'valign,value,vspace,width'); 282 | 283 | // SVG attributes (without "id" and "name" attributes) 284 | // https://wiki.whatwg.org/wiki/Sanitization_rules#svg_Attributes 285 | var svgAttrs = toMap('accent-height,accumulate,additive,alphabetic,arabic-form,ascent,' + 286 | 'baseProfile,bbox,begin,by,calcMode,cap-height,class,color,color-rendering,content,' + 287 | 'cx,cy,d,dx,dy,descent,display,dur,end,fill,fill-rule,font-family,font-size,font-stretch,' + 288 | 'font-style,font-variant,font-weight,from,fx,fy,g1,g2,glyph-name,gradientUnits,hanging,' + 289 | 'height,horiz-adv-x,horiz-origin-x,ideographic,k,keyPoints,keySplines,keyTimes,lang,' + 290 | 'marker-end,marker-mid,marker-start,markerHeight,markerUnits,markerWidth,mathematical,' + 291 | 'max,min,offset,opacity,orient,origin,overline-position,overline-thickness,panose-1,' + 292 | 'path,pathLength,points,preserveAspectRatio,r,refX,refY,repeatCount,repeatDur,' + 293 | 'requiredExtensions,requiredFeatures,restart,rotate,rx,ry,slope,stemh,stemv,stop-color,' + 294 | 'stop-opacity,strikethrough-position,strikethrough-thickness,stroke,stroke-dasharray,' + 295 | 'stroke-dashoffset,stroke-linecap,stroke-linejoin,stroke-miterlimit,stroke-opacity,' + 296 | 'stroke-width,systemLanguage,target,text-anchor,to,transform,type,u1,u2,underline-position,' + 297 | 'underline-thickness,unicode,unicode-range,units-per-em,values,version,viewBox,visibility,' + 298 | 'width,widths,x,x-height,x1,x2,xlink:actuate,xlink:arcrole,xlink:role,xlink:show,xlink:title,' + 299 | 'xlink:type,xml:base,xml:lang,xml:space,xmlns,xmlns:xlink,y,y1,y2,zoomAndPan', true); 300 | 301 | var validAttrs = extend({}, 302 | uriAttrs, 303 | svgAttrs, 304 | htmlAttrs); 305 | 306 | function toMap(str, lowercaseKeys) { 307 | var obj = {}, items = str.split(','), i; 308 | for (i = 0; i < items.length; i++) { 309 | obj[lowercaseKeys ? lowercase(items[i]) : items[i]] = true; 310 | } 311 | return obj; 312 | } 313 | 314 | var inertBodyElement; 315 | (function(window) { 316 | var doc; 317 | if (window.document && window.document.implementation) { 318 | doc = window.document.implementation.createHTMLDocument("inert"); 319 | } else { 320 | throw $sanitizeMinErr('noinert', "Can't create an inert html document"); 321 | } 322 | var docElement = doc.documentElement || doc.getDocumentElement(); 323 | var bodyElements = docElement.getElementsByTagName('body'); 324 | 325 | // usually there should be only one body element in the document, but IE doesn't have any, so we need to create one 326 | if (bodyElements.length === 1) { 327 | inertBodyElement = bodyElements[0]; 328 | } else { 329 | var html = doc.createElement('html'); 330 | inertBodyElement = doc.createElement('body'); 331 | html.appendChild(inertBodyElement); 332 | doc.appendChild(html); 333 | } 334 | })(window); 335 | 336 | /** 337 | * @example 338 | * htmlParser(htmlString, { 339 | * start: function(tag, attrs) {}, 340 | * end: function(tag) {}, 341 | * chars: function(text) {}, 342 | * comment: function(text) {} 343 | * }); 344 | * 345 | * @param {string} html string 346 | * @param {object} handler 347 | */ 348 | function htmlParserImpl(html, handler) { 349 | if (html === null || html === undefined) { 350 | html = ''; 351 | } else if (typeof html !== 'string') { 352 | html = '' + html; 353 | } 354 | inertBodyElement.innerHTML = html; 355 | 356 | //mXSS protection 357 | var mXSSAttempts = 5; 358 | do { 359 | if (mXSSAttempts === 0) { 360 | throw $sanitizeMinErr('uinput', "Failed to sanitize html because the input is unstable"); 361 | } 362 | mXSSAttempts--; 363 | 364 | // strip custom-namespaced attributes on IE<=11 365 | if (window.document.documentMode) { 366 | stripCustomNsAttrs(inertBodyElement); 367 | } 368 | html = inertBodyElement.innerHTML; //trigger mXSS 369 | inertBodyElement.innerHTML = html; 370 | } while (html !== inertBodyElement.innerHTML); 371 | 372 | var node = inertBodyElement.firstChild; 373 | while (node) { 374 | switch (node.nodeType) { 375 | case 1: // ELEMENT_NODE 376 | handler.start(node.nodeName.toLowerCase(), attrToMap(node.attributes)); 377 | break; 378 | case 3: // TEXT NODE 379 | handler.chars(node.textContent); 380 | break; 381 | } 382 | 383 | var nextNode; 384 | if (!(nextNode = node.firstChild)) { 385 | if (node.nodeType == 1) { 386 | handler.end(node.nodeName.toLowerCase()); 387 | } 388 | nextNode = node.nextSibling; 389 | if (!nextNode) { 390 | while (nextNode == null) { 391 | node = node.parentNode; 392 | if (node === inertBodyElement) break; 393 | nextNode = node.nextSibling; 394 | if (node.nodeType == 1) { 395 | handler.end(node.nodeName.toLowerCase()); 396 | } 397 | } 398 | } 399 | } 400 | node = nextNode; 401 | } 402 | 403 | while (node = inertBodyElement.firstChild) { 404 | inertBodyElement.removeChild(node); 405 | } 406 | } 407 | 408 | function attrToMap(attrs) { 409 | var map = {}; 410 | for (var i = 0, ii = attrs.length; i < ii; i++) { 411 | var attr = attrs[i]; 412 | map[attr.name] = attr.value; 413 | } 414 | return map; 415 | } 416 | 417 | 418 | /** 419 | * Escapes all potentially dangerous characters, so that the 420 | * resulting string can be safely inserted into attribute or 421 | * element text. 422 | * @param value 423 | * @returns {string} escaped text 424 | */ 425 | function encodeEntities(value) { 426 | return value. 427 | replace(/&/g, '&'). 428 | replace(SURROGATE_PAIR_REGEXP, function(value) { 429 | var hi = value.charCodeAt(0); 430 | var low = value.charCodeAt(1); 431 | return '&#' + (((hi - 0xD800) * 0x400) + (low - 0xDC00) + 0x10000) + ';'; 432 | }). 433 | replace(NON_ALPHANUMERIC_REGEXP, function(value) { 434 | return '&#' + value.charCodeAt(0) + ';'; 435 | }). 436 | replace(//g, '>'); 438 | } 439 | 440 | /** 441 | * create an HTML/XML writer which writes to buffer 442 | * @param {Array} buf use buf.join('') to get out sanitized html string 443 | * @returns {object} in the form of { 444 | * start: function(tag, attrs) {}, 445 | * end: function(tag) {}, 446 | * chars: function(text) {}, 447 | * comment: function(text) {} 448 | * } 449 | */ 450 | function htmlSanitizeWriterImpl(buf, uriValidator) { 451 | var ignoreCurrentElement = false; 452 | var out = bind(buf, buf.push); 453 | return { 454 | start: function(tag, attrs) { 455 | tag = lowercase(tag); 456 | if (!ignoreCurrentElement && blockedElements[tag]) { 457 | ignoreCurrentElement = tag; 458 | } 459 | if (!ignoreCurrentElement && validElements[tag] === true) { 460 | out('<'); 461 | out(tag); 462 | forEach(attrs, function(value, key) { 463 | var lkey = lowercase(key); 464 | var isImage = (tag === 'img' && lkey === 'src') || (lkey === 'background'); 465 | if (validAttrs[lkey] === true && 466 | (uriAttrs[lkey] !== true || uriValidator(value, isImage))) { 467 | out(' '); 468 | out(key); 469 | out('="'); 470 | out(encodeEntities(value)); 471 | out('"'); 472 | } 473 | }); 474 | out('>'); 475 | } 476 | }, 477 | end: function(tag) { 478 | tag = lowercase(tag); 479 | if (!ignoreCurrentElement && validElements[tag] === true && voidElements[tag] !== true) { 480 | out(''); 483 | } 484 | if (tag == ignoreCurrentElement) { 485 | ignoreCurrentElement = false; 486 | } 487 | }, 488 | chars: function(chars) { 489 | if (!ignoreCurrentElement) { 490 | out(encodeEntities(chars)); 491 | } 492 | } 493 | }; 494 | } 495 | 496 | 497 | /** 498 | * When IE9-11 comes across an unknown namespaced attribute e.g. 'xlink:foo' it adds 'xmlns:ns1' attribute to declare 499 | * ns1 namespace and prefixes the attribute with 'ns1' (e.g. 'ns1:xlink:foo'). This is undesirable since we don't want 500 | * to allow any of these custom attributes. This method strips them all. 501 | * 502 | * @param node Root element to process 503 | */ 504 | function stripCustomNsAttrs(node) { 505 | if (node.nodeType === window.Node.ELEMENT_NODE) { 506 | var attrs = node.attributes; 507 | for (var i = 0, l = attrs.length; i < l; i++) { 508 | var attrNode = attrs[i]; 509 | var attrName = attrNode.name.toLowerCase(); 510 | if (attrName === 'xmlns:ns1' || attrName.lastIndexOf('ns1:', 0) === 0) { 511 | node.removeAttributeNode(attrNode); 512 | i--; 513 | l--; 514 | } 515 | } 516 | } 517 | 518 | var nextNode = node.firstChild; 519 | if (nextNode) { 520 | stripCustomNsAttrs(nextNode); 521 | } 522 | 523 | nextNode = node.nextSibling; 524 | if (nextNode) { 525 | stripCustomNsAttrs(nextNode); 526 | } 527 | } 528 | } 529 | 530 | function sanitizeText(chars) { 531 | var buf = []; 532 | var writer = htmlSanitizeWriter(buf, noop); 533 | writer.chars(chars); 534 | return buf.join(''); 535 | } 536 | 537 | 538 | // define ngSanitize module and register $sanitize service 539 | angular.module('ngSanitize', []).provider('$sanitize', $SanitizeProvider); 540 | 541 | /** 542 | * @ngdoc filter 543 | * @name linky 544 | * @kind function 545 | * 546 | * @description 547 | * Finds links in text input and turns them into html links. Supports `http/https/ftp/mailto` and 548 | * plain email address links. 549 | * 550 | * Requires the {@link ngSanitize `ngSanitize`} module to be installed. 551 | * 552 | * @param {string} text Input text. 553 | * @param {string} target Window (`_blank|_self|_parent|_top`) or named frame to open links in. 554 | * @param {object|function(url)} [attributes] Add custom attributes to the link element. 555 | * 556 | * Can be one of: 557 | * 558 | * - `object`: A map of attributes 559 | * - `function`: Takes the url as a parameter and returns a map of attributes 560 | * 561 | * If the map of attributes contains a value for `target`, it overrides the value of 562 | * the target parameter. 563 | * 564 | * 565 | * @returns {string} Html-linkified and {@link $sanitize sanitized} text. 566 | * 567 | * @usage 568 | 569 | * 570 | * @example 571 | 572 | 573 |
574 | Snippet: 575 | 576 | 577 | 578 | 579 | 580 | 581 | 582 | 583 | 586 | 589 | 590 | 591 | 592 | 595 | 598 | 599 | 600 | 601 | 604 | 607 | 608 | 609 | 610 | 611 | 612 | 613 |
FilterSourceRendered
linky filter 584 |
<div ng-bind-html="snippet | linky">
</div>
585 |
587 |
588 |
linky target 593 |
<div ng-bind-html="snippetWithSingleURL | linky:'_blank'">
</div>
594 |
596 |
597 |
linky custom attributes 602 |
<div ng-bind-html="snippetWithSingleURL | linky:'_self':{rel: 'nofollow'}">
</div>
603 |
605 |
606 |
no filter
<div ng-bind="snippet">
</div>
614 | 615 | 616 | angular.module('linkyExample', ['ngSanitize']) 617 | .controller('ExampleController', ['$scope', function($scope) { 618 | $scope.snippet = 619 | 'Pretty text with some links:\n'+ 620 | 'http://angularjs.org/,\n'+ 621 | 'mailto:us@somewhere.org,\n'+ 622 | 'another@somewhere.org,\n'+ 623 | 'and one more: ftp://127.0.0.1/.'; 624 | $scope.snippetWithSingleURL = 'http://angularjs.org/'; 625 | }]); 626 | 627 | 628 | it('should linkify the snippet with urls', function() { 629 | expect(element(by.id('linky-filter')).element(by.binding('snippet | linky')).getText()). 630 | toBe('Pretty text with some links: http://angularjs.org/, us@somewhere.org, ' + 631 | 'another@somewhere.org, and one more: ftp://127.0.0.1/.'); 632 | expect(element.all(by.css('#linky-filter a')).count()).toEqual(4); 633 | }); 634 | 635 | it('should not linkify snippet without the linky filter', function() { 636 | expect(element(by.id('escaped-html')).element(by.binding('snippet')).getText()). 637 | toBe('Pretty text with some links: http://angularjs.org/, mailto:us@somewhere.org, ' + 638 | 'another@somewhere.org, and one more: ftp://127.0.0.1/.'); 639 | expect(element.all(by.css('#escaped-html a')).count()).toEqual(0); 640 | }); 641 | 642 | it('should update', function() { 643 | element(by.model('snippet')).clear(); 644 | element(by.model('snippet')).sendKeys('new http://link.'); 645 | expect(element(by.id('linky-filter')).element(by.binding('snippet | linky')).getText()). 646 | toBe('new http://link.'); 647 | expect(element.all(by.css('#linky-filter a')).count()).toEqual(1); 648 | expect(element(by.id('escaped-html')).element(by.binding('snippet')).getText()) 649 | .toBe('new http://link.'); 650 | }); 651 | 652 | it('should work with the target property', function() { 653 | expect(element(by.id('linky-target')). 654 | element(by.binding("snippetWithSingleURL | linky:'_blank'")).getText()). 655 | toBe('http://angularjs.org/'); 656 | expect(element(by.css('#linky-target a')).getAttribute('target')).toEqual('_blank'); 657 | }); 658 | 659 | it('should optionally add custom attributes', function() { 660 | expect(element(by.id('linky-custom-attributes')). 661 | element(by.binding("snippetWithSingleURL | linky:'_self':{rel: 'nofollow'}")).getText()). 662 | toBe('http://angularjs.org/'); 663 | expect(element(by.css('#linky-custom-attributes a')).getAttribute('rel')).toEqual('nofollow'); 664 | }); 665 | 666 | 667 | */ 668 | angular.module('ngSanitize').filter('linky', ['$sanitize', function($sanitize) { 669 | var LINKY_URL_REGEXP = 670 | /((ftp|https?):\/\/|(www\.)|(mailto:)?[A-Za-z0-9._%+-]+@)\S*[^\s.;,(){}<>"\u201d\u2019]/i, 671 | MAILTO_REGEXP = /^mailto:/i; 672 | 673 | var linkyMinErr = angular.$$minErr('linky'); 674 | var isDefined = angular.isDefined; 675 | var isFunction = angular.isFunction; 676 | var isObject = angular.isObject; 677 | var isString = angular.isString; 678 | 679 | return function(text, target, attributes) { 680 | if (text == null || text === '') return text; 681 | if (!isString(text)) throw linkyMinErr('notstring', 'Expected string but received: {0}', text); 682 | 683 | var attributesFn = 684 | isFunction(attributes) ? attributes : 685 | isObject(attributes) ? function getAttributesObject() {return attributes;} : 686 | function getEmptyAttributesObject() {return {};}; 687 | 688 | var match; 689 | var raw = text; 690 | var html = []; 691 | var url; 692 | var i; 693 | while ((match = raw.match(LINKY_URL_REGEXP))) { 694 | // We can not end in these as they are sometimes found at the end of the sentence 695 | url = match[0]; 696 | // if we did not match ftp/http/www/mailto then assume mailto 697 | if (!match[2] && !match[4]) { 698 | url = (match[3] ? 'http://' : 'mailto:') + url; 699 | } 700 | i = match.index; 701 | addText(raw.substr(0, i)); 702 | addLink(url, match[0].replace(MAILTO_REGEXP, '')); 703 | raw = raw.substring(i + match[0].length); 704 | } 705 | addText(raw); 706 | return $sanitize(html.join('')); 707 | 708 | function addText(text) { 709 | if (!text) { 710 | return; 711 | } 712 | html.push(sanitizeText(text)); 713 | } 714 | 715 | function addLink(url, text) { 716 | var key, linkAttributes = attributesFn(url); 717 | html.push(''); 731 | addText(text); 732 | html.push(''); 733 | } 734 | }; 735 | }]); 736 | 737 | 738 | })(window, window.angular); 739 | -------------------------------------------------------------------------------- /vendor/angular-resource.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license AngularJS v1.5.8 3 | * (c) 2010-2016 Google, Inc. http://angularjs.org 4 | * License: MIT 5 | */ 6 | (function(window, angular) {'use strict'; 7 | 8 | var $resourceMinErr = angular.$$minErr('$resource'); 9 | 10 | // Helper functions and regex to lookup a dotted path on an object 11 | // stopping at undefined/null. The path must be composed of ASCII 12 | // identifiers (just like $parse) 13 | var MEMBER_NAME_REGEX = /^(\.[a-zA-Z_$@][0-9a-zA-Z_$@]*)+$/; 14 | 15 | function isValidDottedPath(path) { 16 | return (path != null && path !== '' && path !== 'hasOwnProperty' && 17 | MEMBER_NAME_REGEX.test('.' + path)); 18 | } 19 | 20 | function lookupDottedPath(obj, path) { 21 | if (!isValidDottedPath(path)) { 22 | throw $resourceMinErr('badmember', 'Dotted member path "@{0}" is invalid.', path); 23 | } 24 | var keys = path.split('.'); 25 | for (var i = 0, ii = keys.length; i < ii && angular.isDefined(obj); i++) { 26 | var key = keys[i]; 27 | obj = (obj !== null) ? obj[key] : undefined; 28 | } 29 | return obj; 30 | } 31 | 32 | /** 33 | * Create a shallow copy of an object and clear other fields from the destination 34 | */ 35 | function shallowClearAndCopy(src, dst) { 36 | dst = dst || {}; 37 | 38 | angular.forEach(dst, function(value, key) { 39 | delete dst[key]; 40 | }); 41 | 42 | for (var key in src) { 43 | if (src.hasOwnProperty(key) && !(key.charAt(0) === '$' && key.charAt(1) === '$')) { 44 | dst[key] = src[key]; 45 | } 46 | } 47 | 48 | return dst; 49 | } 50 | 51 | /** 52 | * @ngdoc module 53 | * @name ngResource 54 | * @description 55 | * 56 | * # ngResource 57 | * 58 | * The `ngResource` module provides interaction support with RESTful services 59 | * via the $resource service. 60 | * 61 | * 62 | *
63 | * 64 | * See {@link ngResource.$resourceProvider} and {@link ngResource.$resource} for usage. 65 | */ 66 | 67 | /** 68 | * @ngdoc provider 69 | * @name $resourceProvider 70 | * 71 | * @description 72 | * 73 | * Use `$resourceProvider` to change the default behavior of the {@link ngResource.$resource} 74 | * service. 75 | * 76 | * ## Dependencies 77 | * Requires the {@link ngResource } module to be installed. 78 | * 79 | */ 80 | 81 | /** 82 | * @ngdoc service 83 | * @name $resource 84 | * @requires $http 85 | * @requires ng.$log 86 | * @requires $q 87 | * @requires ng.$timeout 88 | * 89 | * @description 90 | * A factory which creates a resource object that lets you interact with 91 | * [RESTful](http://en.wikipedia.org/wiki/Representational_State_Transfer) server-side data sources. 92 | * 93 | * The returned resource object has action methods which provide high-level behaviors without 94 | * the need to interact with the low level {@link ng.$http $http} service. 95 | * 96 | * Requires the {@link ngResource `ngResource`} module to be installed. 97 | * 98 | * By default, trailing slashes will be stripped from the calculated URLs, 99 | * which can pose problems with server backends that do not expect that 100 | * behavior. This can be disabled by configuring the `$resourceProvider` like 101 | * this: 102 | * 103 | * ```js 104 | app.config(['$resourceProvider', function($resourceProvider) { 105 | // Don't strip trailing slashes from calculated URLs 106 | $resourceProvider.defaults.stripTrailingSlashes = false; 107 | }]); 108 | * ``` 109 | * 110 | * @param {string} url A parameterized URL template with parameters prefixed by `:` as in 111 | * `/user/:username`. If you are using a URL with a port number (e.g. 112 | * `http://example.com:8080/api`), it will be respected. 113 | * 114 | * If you are using a url with a suffix, just add the suffix, like this: 115 | * `$resource('http://example.com/resource.json')` or `$resource('http://example.com/:id.json')` 116 | * or even `$resource('http://example.com/resource/:resource_id.:format')` 117 | * If the parameter before the suffix is empty, :resource_id in this case, then the `/.` will be 118 | * collapsed down to a single `.`. If you need this sequence to appear and not collapse then you 119 | * can escape it with `/\.`. 120 | * 121 | * @param {Object=} paramDefaults Default values for `url` parameters. These can be overridden in 122 | * `actions` methods. If a parameter value is a function, it will be called every time 123 | * a param value needs to be obtained for a request (unless the param was overridden). The function 124 | * will be passed the current data value as an argument. 125 | * 126 | * Each key value in the parameter object is first bound to url template if present and then any 127 | * excess keys are appended to the url search query after the `?`. 128 | * 129 | * Given a template `/path/:verb` and parameter `{verb:'greet', salutation:'Hello'}` results in 130 | * URL `/path/greet?salutation=Hello`. 131 | * 132 | * If the parameter value is prefixed with `@`, then the value for that parameter will be 133 | * extracted from the corresponding property on the `data` object (provided when calling a 134 | * "non-GET" action method). 135 | * For example, if the `defaultParam` object is `{someParam: '@someProp'}` then the value of 136 | * `someParam` will be `data.someProp`. 137 | * Note that the parameter will be ignored, when calling a "GET" action method (i.e. an action 138 | * method that does not accept a request body) 139 | * 140 | * @param {Object.=} actions Hash with declaration of custom actions that should extend 141 | * the default set of resource actions. The declaration should be created in the format of {@link 142 | * ng.$http#usage $http.config}: 143 | * 144 | * {action1: {method:?, params:?, isArray:?, headers:?, ...}, 145 | * action2: {method:?, params:?, isArray:?, headers:?, ...}, 146 | * ...} 147 | * 148 | * Where: 149 | * 150 | * - **`action`** – {string} – The name of action. This name becomes the name of the method on 151 | * your resource object. 152 | * - **`method`** – {string} – Case insensitive HTTP method (e.g. `GET`, `POST`, `PUT`, 153 | * `DELETE`, `JSONP`, etc). 154 | * - **`params`** – {Object=} – Optional set of pre-bound parameters for this action. If any of 155 | * the parameter value is a function, it will be called every time when a param value needs to 156 | * be obtained for a request (unless the param was overridden). The function will be passed the 157 | * current data value as an argument. 158 | * - **`url`** – {string} – action specific `url` override. The url templating is supported just 159 | * like for the resource-level urls. 160 | * - **`isArray`** – {boolean=} – If true then the returned object for this action is an array, 161 | * see `returns` section. 162 | * - **`transformRequest`** – 163 | * `{function(data, headersGetter)|Array.}` – 164 | * transform function or an array of such functions. The transform function takes the http 165 | * request body and headers and returns its transformed (typically serialized) version. 166 | * By default, transformRequest will contain one function that checks if the request data is 167 | * an object and serializes to using `angular.toJson`. To prevent this behavior, set 168 | * `transformRequest` to an empty array: `transformRequest: []` 169 | * - **`transformResponse`** – 170 | * `{function(data, headersGetter)|Array.}` – 171 | * transform function or an array of such functions. The transform function takes the http 172 | * response body and headers and returns its transformed (typically deserialized) version. 173 | * By default, transformResponse will contain one function that checks if the response looks 174 | * like a JSON string and deserializes it using `angular.fromJson`. To prevent this behavior, 175 | * set `transformResponse` to an empty array: `transformResponse: []` 176 | * - **`cache`** – `{boolean|Cache}` – If true, a default $http cache will be used to cache the 177 | * GET request, otherwise if a cache instance built with 178 | * {@link ng.$cacheFactory $cacheFactory}, this cache will be used for 179 | * caching. 180 | * - **`timeout`** – `{number}` – timeout in milliseconds.
181 | * **Note:** In contrast to {@link ng.$http#usage $http.config}, {@link ng.$q promises} are 182 | * **not** supported in $resource, because the same value would be used for multiple requests. 183 | * If you are looking for a way to cancel requests, you should use the `cancellable` option. 184 | * - **`cancellable`** – `{boolean}` – if set to true, the request made by a "non-instance" call 185 | * will be cancelled (if not already completed) by calling `$cancelRequest()` on the call's 186 | * return value. Calling `$cancelRequest()` for a non-cancellable or an already 187 | * completed/cancelled request will have no effect.
188 | * - **`withCredentials`** - `{boolean}` - whether to set the `withCredentials` flag on the 189 | * XHR object. See 190 | * [requests with credentials](https://developer.mozilla.org/en/http_access_control#section_5) 191 | * for more information. 192 | * - **`responseType`** - `{string}` - see 193 | * [requestType](https://developer.mozilla.org/en-US/docs/DOM/XMLHttpRequest#responseType). 194 | * - **`interceptor`** - `{Object=}` - The interceptor object has two optional methods - 195 | * `response` and `responseError`. Both `response` and `responseError` interceptors get called 196 | * with `http response` object. See {@link ng.$http $http interceptors}. 197 | * 198 | * @param {Object} options Hash with custom settings that should extend the 199 | * default `$resourceProvider` behavior. The supported options are: 200 | * 201 | * - **`stripTrailingSlashes`** – {boolean} – If true then the trailing 202 | * slashes from any calculated URL will be stripped. (Defaults to true.) 203 | * - **`cancellable`** – {boolean} – If true, the request made by a "non-instance" call will be 204 | * cancelled (if not already completed) by calling `$cancelRequest()` on the call's return value. 205 | * This can be overwritten per action. (Defaults to false.) 206 | * 207 | * @returns {Object} A resource "class" object with methods for the default set of resource actions 208 | * optionally extended with custom `actions`. The default set contains these actions: 209 | * ```js 210 | * { 'get': {method:'GET'}, 211 | * 'save': {method:'POST'}, 212 | * 'query': {method:'GET', isArray:true}, 213 | * 'remove': {method:'DELETE'}, 214 | * 'delete': {method:'DELETE'} }; 215 | * ``` 216 | * 217 | * Calling these methods invoke an {@link ng.$http} with the specified http method, 218 | * destination and parameters. When the data is returned from the server then the object is an 219 | * instance of the resource class. The actions `save`, `remove` and `delete` are available on it 220 | * as methods with the `$` prefix. This allows you to easily perform CRUD operations (create, 221 | * read, update, delete) on server-side data like this: 222 | * ```js 223 | * var User = $resource('/user/:userId', {userId:'@id'}); 224 | * var user = User.get({userId:123}, function() { 225 | * user.abc = true; 226 | * user.$save(); 227 | * }); 228 | * ``` 229 | * 230 | * It is important to realize that invoking a $resource object method immediately returns an 231 | * empty reference (object or array depending on `isArray`). Once the data is returned from the 232 | * server the existing reference is populated with the actual data. This is a useful trick since 233 | * usually the resource is assigned to a model which is then rendered by the view. Having an empty 234 | * object results in no rendering, once the data arrives from the server then the object is 235 | * populated with the data and the view automatically re-renders itself showing the new data. This 236 | * means that in most cases one never has to write a callback function for the action methods. 237 | * 238 | * The action methods on the class object or instance object can be invoked with the following 239 | * parameters: 240 | * 241 | * - HTTP GET "class" actions: `Resource.action([parameters], [success], [error])` 242 | * - non-GET "class" actions: `Resource.action([parameters], postData, [success], [error])` 243 | * - non-GET instance actions: `instance.$action([parameters], [success], [error])` 244 | * 245 | * 246 | * Success callback is called with (value, responseHeaders) arguments, where the value is 247 | * the populated resource instance or collection object. The error callback is called 248 | * with (httpResponse) argument. 249 | * 250 | * Class actions return empty instance (with additional properties below). 251 | * Instance actions return promise of the action. 252 | * 253 | * The Resource instances and collections have these additional properties: 254 | * 255 | * - `$promise`: the {@link ng.$q promise} of the original server interaction that created this 256 | * instance or collection. 257 | * 258 | * On success, the promise is resolved with the same resource instance or collection object, 259 | * updated with data from server. This makes it easy to use in 260 | * {@link ngRoute.$routeProvider resolve section of $routeProvider.when()} to defer view 261 | * rendering until the resource(s) are loaded. 262 | * 263 | * On failure, the promise is rejected with the {@link ng.$http http response} object, without 264 | * the `resource` property. 265 | * 266 | * If an interceptor object was provided, the promise will instead be resolved with the value 267 | * returned by the interceptor. 268 | * 269 | * - `$resolved`: `true` after first server interaction is completed (either with success or 270 | * rejection), `false` before that. Knowing if the Resource has been resolved is useful in 271 | * data-binding. 272 | * 273 | * The Resource instances and collections have these additional methods: 274 | * 275 | * - `$cancelRequest`: If there is a cancellable, pending request related to the instance or 276 | * collection, calling this method will abort the request. 277 | * 278 | * The Resource instances have these additional methods: 279 | * 280 | * - `toJSON`: It returns a simple object without any of the extra properties added as part of 281 | * the Resource API. This object can be serialized through {@link angular.toJson} safely 282 | * without attaching Angular-specific fields. Notice that `JSON.stringify` (and 283 | * `angular.toJson`) automatically use this method when serializing a Resource instance 284 | * (see [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#toJSON()_behavior)). 285 | * 286 | * @example 287 | * 288 | * # Credit card resource 289 | * 290 | * ```js 291 | // Define CreditCard class 292 | var CreditCard = $resource('/user/:userId/card/:cardId', 293 | {userId:123, cardId:'@id'}, { 294 | charge: {method:'POST', params:{charge:true}} 295 | }); 296 | 297 | // We can retrieve a collection from the server 298 | var cards = CreditCard.query(function() { 299 | // GET: /user/123/card 300 | // server returns: [ {id:456, number:'1234', name:'Smith'} ]; 301 | 302 | var card = cards[0]; 303 | // each item is an instance of CreditCard 304 | expect(card instanceof CreditCard).toEqual(true); 305 | card.name = "J. Smith"; 306 | // non GET methods are mapped onto the instances 307 | card.$save(); 308 | // POST: /user/123/card/456 {id:456, number:'1234', name:'J. Smith'} 309 | // server returns: {id:456, number:'1234', name: 'J. Smith'}; 310 | 311 | // our custom method is mapped as well. 312 | card.$charge({amount:9.99}); 313 | // POST: /user/123/card/456?amount=9.99&charge=true {id:456, number:'1234', name:'J. Smith'} 314 | }); 315 | 316 | // we can create an instance as well 317 | var newCard = new CreditCard({number:'0123'}); 318 | newCard.name = "Mike Smith"; 319 | newCard.$save(); 320 | // POST: /user/123/card {number:'0123', name:'Mike Smith'} 321 | // server returns: {id:789, number:'0123', name: 'Mike Smith'}; 322 | expect(newCard.id).toEqual(789); 323 | * ``` 324 | * 325 | * The object returned from this function execution is a resource "class" which has "static" method 326 | * for each action in the definition. 327 | * 328 | * Calling these methods invoke `$http` on the `url` template with the given `method`, `params` and 329 | * `headers`. 330 | * 331 | * @example 332 | * 333 | * # User resource 334 | * 335 | * When the data is returned from the server then the object is an instance of the resource type and 336 | * all of the non-GET methods are available with `$` prefix. This allows you to easily support CRUD 337 | * operations (create, read, update, delete) on server-side data. 338 | 339 | ```js 340 | var User = $resource('/user/:userId', {userId:'@id'}); 341 | User.get({userId:123}, function(user) { 342 | user.abc = true; 343 | user.$save(); 344 | }); 345 | ``` 346 | * 347 | * It's worth noting that the success callback for `get`, `query` and other methods gets passed 348 | * in the response that came from the server as well as $http header getter function, so one 349 | * could rewrite the above example and get access to http headers as: 350 | * 351 | ```js 352 | var User = $resource('/user/:userId', {userId:'@id'}); 353 | User.get({userId:123}, function(user, getResponseHeaders){ 354 | user.abc = true; 355 | user.$save(function(user, putResponseHeaders) { 356 | //user => saved user object 357 | //putResponseHeaders => $http header getter 358 | }); 359 | }); 360 | ``` 361 | * 362 | * You can also access the raw `$http` promise via the `$promise` property on the object returned 363 | * 364 | ``` 365 | var User = $resource('/user/:userId', {userId:'@id'}); 366 | User.get({userId:123}) 367 | .$promise.then(function(user) { 368 | $scope.user = user; 369 | }); 370 | ``` 371 | * 372 | * @example 373 | * 374 | * # Creating a custom 'PUT' request 375 | * 376 | * In this example we create a custom method on our resource to make a PUT request 377 | * ```js 378 | * var app = angular.module('app', ['ngResource', 'ngRoute']); 379 | * 380 | * // Some APIs expect a PUT request in the format URL/object/ID 381 | * // Here we are creating an 'update' method 382 | * app.factory('Notes', ['$resource', function($resource) { 383 | * return $resource('/notes/:id', null, 384 | * { 385 | * 'update': { method:'PUT' } 386 | * }); 387 | * }]); 388 | * 389 | * // In our controller we get the ID from the URL using ngRoute and $routeParams 390 | * // We pass in $routeParams and our Notes factory along with $scope 391 | * app.controller('NotesCtrl', ['$scope', '$routeParams', 'Notes', 392 | function($scope, $routeParams, Notes) { 393 | * // First get a note object from the factory 394 | * var note = Notes.get({ id:$routeParams.id }); 395 | * $id = note.id; 396 | * 397 | * // Now call update passing in the ID first then the object you are updating 398 | * Notes.update({ id:$id }, note); 399 | * 400 | * // This will PUT /notes/ID with the note object in the request payload 401 | * }]); 402 | * ``` 403 | * 404 | * @example 405 | * 406 | * # Cancelling requests 407 | * 408 | * If an action's configuration specifies that it is cancellable, you can cancel the request related 409 | * to an instance or collection (as long as it is a result of a "non-instance" call): 410 | * 411 | ```js 412 | // ...defining the `Hotel` resource... 413 | var Hotel = $resource('/api/hotel/:id', {id: '@id'}, { 414 | // Let's make the `query()` method cancellable 415 | query: {method: 'get', isArray: true, cancellable: true} 416 | }); 417 | 418 | // ...somewhere in the PlanVacationController... 419 | ... 420 | this.onDestinationChanged = function onDestinationChanged(destination) { 421 | // We don't care about any pending request for hotels 422 | // in a different destination any more 423 | this.availableHotels.$cancelRequest(); 424 | 425 | // Let's query for hotels in '' 426 | // (calls: /api/hotel?location=) 427 | this.availableHotels = Hotel.query({location: destination}); 428 | }; 429 | ``` 430 | * 431 | */ 432 | angular.module('ngResource', ['ng']). 433 | provider('$resource', function() { 434 | var PROTOCOL_AND_DOMAIN_REGEX = /^https?:\/\/[^\/]*/; 435 | var provider = this; 436 | 437 | /** 438 | * @ngdoc property 439 | * @name $resourceProvider#defaults 440 | * @description 441 | * Object containing default options used when creating `$resource` instances. 442 | * 443 | * The default values satisfy a wide range of usecases, but you may choose to overwrite any of 444 | * them to further customize your instances. The available properties are: 445 | * 446 | * - **stripTrailingSlashes** – `{boolean}` – If true, then the trailing slashes from any 447 | * calculated URL will be stripped.
448 | * (Defaults to true.) 449 | * - **cancellable** – `{boolean}` – If true, the request made by a "non-instance" call will be 450 | * cancelled (if not already completed) by calling `$cancelRequest()` on the call's return 451 | * value. For more details, see {@link ngResource.$resource}. This can be overwritten per 452 | * resource class or action.
453 | * (Defaults to false.) 454 | * - **actions** - `{Object.}` - A hash with default actions declarations. Actions are 455 | * high-level methods corresponding to RESTful actions/methods on resources. An action may 456 | * specify what HTTP method to use, what URL to hit, if the return value will be a single 457 | * object or a collection (array) of objects etc. For more details, see 458 | * {@link ngResource.$resource}. The actions can also be enhanced or overwritten per resource 459 | * class.
460 | * The default actions are: 461 | * ```js 462 | * { 463 | * get: {method: 'GET'}, 464 | * save: {method: 'POST'}, 465 | * query: {method: 'GET', isArray: true}, 466 | * remove: {method: 'DELETE'}, 467 | * delete: {method: 'DELETE'} 468 | * } 469 | * ``` 470 | * 471 | * #### Example 472 | * 473 | * For example, you can specify a new `update` action that uses the `PUT` HTTP verb: 474 | * 475 | * ```js 476 | * angular. 477 | * module('myApp'). 478 | * config(['resourceProvider', function ($resourceProvider) { 479 | * $resourceProvider.defaults.actions.update = { 480 | * method: 'PUT' 481 | * }; 482 | * }); 483 | * ``` 484 | * 485 | * Or you can even overwrite the whole `actions` list and specify your own: 486 | * 487 | * ```js 488 | * angular. 489 | * module('myApp'). 490 | * config(['resourceProvider', function ($resourceProvider) { 491 | * $resourceProvider.defaults.actions = { 492 | * create: {method: 'POST'} 493 | * get: {method: 'GET'}, 494 | * getAll: {method: 'GET', isArray:true}, 495 | * update: {method: 'PUT'}, 496 | * delete: {method: 'DELETE'} 497 | * }; 498 | * }); 499 | * ``` 500 | * 501 | */ 502 | this.defaults = { 503 | // Strip slashes by default 504 | stripTrailingSlashes: true, 505 | 506 | // Make non-instance requests cancellable (via `$cancelRequest()`) 507 | cancellable: false, 508 | 509 | // Default actions configuration 510 | actions: { 511 | 'get': {method: 'GET'}, 512 | 'save': {method: 'POST'}, 513 | 'query': {method: 'GET', isArray: true}, 514 | 'remove': {method: 'DELETE'}, 515 | 'delete': {method: 'DELETE'} 516 | } 517 | }; 518 | 519 | this.$get = ['$http', '$log', '$q', '$timeout', function($http, $log, $q, $timeout) { 520 | 521 | var noop = angular.noop, 522 | forEach = angular.forEach, 523 | extend = angular.extend, 524 | copy = angular.copy, 525 | isFunction = angular.isFunction; 526 | 527 | /** 528 | * We need our custom method because encodeURIComponent is too aggressive and doesn't follow 529 | * http://www.ietf.org/rfc/rfc3986.txt with regards to the character set 530 | * (pchar) allowed in path segments: 531 | * segment = *pchar 532 | * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" 533 | * pct-encoded = "%" HEXDIG HEXDIG 534 | * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" 535 | * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" 536 | * / "*" / "+" / "," / ";" / "=" 537 | */ 538 | function encodeUriSegment(val) { 539 | return encodeUriQuery(val, true). 540 | replace(/%26/gi, '&'). 541 | replace(/%3D/gi, '='). 542 | replace(/%2B/gi, '+'); 543 | } 544 | 545 | 546 | /** 547 | * This method is intended for encoding *key* or *value* parts of query component. We need a 548 | * custom method because encodeURIComponent is too aggressive and encodes stuff that doesn't 549 | * have to be encoded per http://tools.ietf.org/html/rfc3986: 550 | * query = *( pchar / "/" / "?" ) 551 | * pchar = unreserved / pct-encoded / sub-delims / ":" / "@" 552 | * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" 553 | * pct-encoded = "%" HEXDIG HEXDIG 554 | * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" 555 | * / "*" / "+" / "," / ";" / "=" 556 | */ 557 | function encodeUriQuery(val, pctEncodeSpaces) { 558 | return encodeURIComponent(val). 559 | replace(/%40/gi, '@'). 560 | replace(/%3A/gi, ':'). 561 | replace(/%24/g, '$'). 562 | replace(/%2C/gi, ','). 563 | replace(/%20/g, (pctEncodeSpaces ? '%20' : '+')); 564 | } 565 | 566 | function Route(template, defaults) { 567 | this.template = template; 568 | this.defaults = extend({}, provider.defaults, defaults); 569 | this.urlParams = {}; 570 | } 571 | 572 | Route.prototype = { 573 | setUrlParams: function(config, params, actionUrl) { 574 | var self = this, 575 | url = actionUrl || self.template, 576 | val, 577 | encodedVal, 578 | protocolAndDomain = ''; 579 | 580 | var urlParams = self.urlParams = {}; 581 | forEach(url.split(/\W/), function(param) { 582 | if (param === 'hasOwnProperty') { 583 | throw $resourceMinErr('badname', "hasOwnProperty is not a valid parameter name."); 584 | } 585 | if (!(new RegExp("^\\d+$").test(param)) && param && 586 | (new RegExp("(^|[^\\\\]):" + param + "(\\W|$)").test(url))) { 587 | urlParams[param] = { 588 | isQueryParamValue: (new RegExp("\\?.*=:" + param + "(?:\\W|$)")).test(url) 589 | }; 590 | } 591 | }); 592 | url = url.replace(/\\:/g, ':'); 593 | url = url.replace(PROTOCOL_AND_DOMAIN_REGEX, function(match) { 594 | protocolAndDomain = match; 595 | return ''; 596 | }); 597 | 598 | params = params || {}; 599 | forEach(self.urlParams, function(paramInfo, urlParam) { 600 | val = params.hasOwnProperty(urlParam) ? params[urlParam] : self.defaults[urlParam]; 601 | if (angular.isDefined(val) && val !== null) { 602 | if (paramInfo.isQueryParamValue) { 603 | encodedVal = encodeUriQuery(val, true); 604 | } else { 605 | encodedVal = encodeUriSegment(val); 606 | } 607 | url = url.replace(new RegExp(":" + urlParam + "(\\W|$)", "g"), function(match, p1) { 608 | return encodedVal + p1; 609 | }); 610 | } else { 611 | url = url.replace(new RegExp("(\/?):" + urlParam + "(\\W|$)", "g"), function(match, 612 | leadingSlashes, tail) { 613 | if (tail.charAt(0) == '/') { 614 | return tail; 615 | } else { 616 | return leadingSlashes + tail; 617 | } 618 | }); 619 | } 620 | }); 621 | 622 | // strip trailing slashes and set the url (unless this behavior is specifically disabled) 623 | if (self.defaults.stripTrailingSlashes) { 624 | url = url.replace(/\/+$/, '') || '/'; 625 | } 626 | 627 | // then replace collapse `/.` if found in the last URL path segment before the query 628 | // E.g. `http://url.com/id./format?q=x` becomes `http://url.com/id.format?q=x` 629 | url = url.replace(/\/\.(?=\w+($|\?))/, '.'); 630 | // replace escaped `/\.` with `/.` 631 | config.url = protocolAndDomain + url.replace(/\/\\\./, '/.'); 632 | 633 | 634 | // set params - delegate param encoding to $http 635 | forEach(params, function(value, key) { 636 | if (!self.urlParams[key]) { 637 | config.params = config.params || {}; 638 | config.params[key] = value; 639 | } 640 | }); 641 | } 642 | }; 643 | 644 | 645 | function resourceFactory(url, paramDefaults, actions, options) { 646 | var route = new Route(url, options); 647 | 648 | actions = extend({}, provider.defaults.actions, actions); 649 | 650 | function extractParams(data, actionParams) { 651 | var ids = {}; 652 | actionParams = extend({}, paramDefaults, actionParams); 653 | forEach(actionParams, function(value, key) { 654 | if (isFunction(value)) { value = value(data); } 655 | ids[key] = value && value.charAt && value.charAt(0) == '@' ? 656 | lookupDottedPath(data, value.substr(1)) : value; 657 | }); 658 | return ids; 659 | } 660 | 661 | function defaultResponseInterceptor(response) { 662 | return response.resource; 663 | } 664 | 665 | function Resource(value) { 666 | shallowClearAndCopy(value || {}, this); 667 | } 668 | 669 | Resource.prototype.toJSON = function() { 670 | var data = extend({}, this); 671 | delete data.$promise; 672 | delete data.$resolved; 673 | return data; 674 | }; 675 | 676 | forEach(actions, function(action, name) { 677 | var hasBody = /^(POST|PUT|PATCH)$/i.test(action.method); 678 | var numericTimeout = action.timeout; 679 | var cancellable = angular.isDefined(action.cancellable) ? action.cancellable : 680 | (options && angular.isDefined(options.cancellable)) ? options.cancellable : 681 | provider.defaults.cancellable; 682 | 683 | if (numericTimeout && !angular.isNumber(numericTimeout)) { 684 | $log.debug('ngResource:\n' + 685 | ' Only numeric values are allowed as `timeout`.\n' + 686 | ' Promises are not supported in $resource, because the same value would ' + 687 | 'be used for multiple requests. If you are looking for a way to cancel ' + 688 | 'requests, you should use the `cancellable` option.'); 689 | delete action.timeout; 690 | numericTimeout = null; 691 | } 692 | 693 | Resource[name] = function(a1, a2, a3, a4) { 694 | var params = {}, data, success, error; 695 | 696 | /* jshint -W086 */ /* (purposefully fall through case statements) */ 697 | switch (arguments.length) { 698 | case 4: 699 | error = a4; 700 | success = a3; 701 | //fallthrough 702 | case 3: 703 | case 2: 704 | if (isFunction(a2)) { 705 | if (isFunction(a1)) { 706 | success = a1; 707 | error = a2; 708 | break; 709 | } 710 | 711 | success = a2; 712 | error = a3; 713 | //fallthrough 714 | } else { 715 | params = a1; 716 | data = a2; 717 | success = a3; 718 | break; 719 | } 720 | case 1: 721 | if (isFunction(a1)) success = a1; 722 | else if (hasBody) data = a1; 723 | else params = a1; 724 | break; 725 | case 0: break; 726 | default: 727 | throw $resourceMinErr('badargs', 728 | "Expected up to 4 arguments [params, data, success, error], got {0} arguments", 729 | arguments.length); 730 | } 731 | /* jshint +W086 */ /* (purposefully fall through case statements) */ 732 | 733 | var isInstanceCall = this instanceof Resource; 734 | var value = isInstanceCall ? data : (action.isArray ? [] : new Resource(data)); 735 | var httpConfig = {}; 736 | var responseInterceptor = action.interceptor && action.interceptor.response || 737 | defaultResponseInterceptor; 738 | var responseErrorInterceptor = action.interceptor && action.interceptor.responseError || 739 | undefined; 740 | var timeoutDeferred; 741 | var numericTimeoutPromise; 742 | 743 | forEach(action, function(value, key) { 744 | switch (key) { 745 | default: 746 | httpConfig[key] = copy(value); 747 | break; 748 | case 'params': 749 | case 'isArray': 750 | case 'interceptor': 751 | case 'cancellable': 752 | break; 753 | } 754 | }); 755 | 756 | if (!isInstanceCall && cancellable) { 757 | timeoutDeferred = $q.defer(); 758 | httpConfig.timeout = timeoutDeferred.promise; 759 | 760 | if (numericTimeout) { 761 | numericTimeoutPromise = $timeout(timeoutDeferred.resolve, numericTimeout); 762 | } 763 | } 764 | 765 | if (hasBody) httpConfig.data = data; 766 | route.setUrlParams(httpConfig, 767 | extend({}, extractParams(data, action.params || {}), params), 768 | action.url); 769 | 770 | var promise = $http(httpConfig).then(function(response) { 771 | var data = response.data; 772 | 773 | if (data) { 774 | // Need to convert action.isArray to boolean in case it is undefined 775 | // jshint -W018 776 | if (angular.isArray(data) !== (!!action.isArray)) { 777 | throw $resourceMinErr('badcfg', 778 | 'Error in resource configuration for action `{0}`. Expected response to ' + 779 | 'contain an {1} but got an {2} (Request: {3} {4})', name, action.isArray ? 'array' : 'object', 780 | angular.isArray(data) ? 'array' : 'object', httpConfig.method, httpConfig.url); 781 | } 782 | // jshint +W018 783 | if (action.isArray) { 784 | value.length = 0; 785 | forEach(data, function(item) { 786 | if (typeof item === "object") { 787 | value.push(new Resource(item)); 788 | } else { 789 | // Valid JSON values may be string literals, and these should not be converted 790 | // into objects. These items will not have access to the Resource prototype 791 | // methods, but unfortunately there 792 | value.push(item); 793 | } 794 | }); 795 | } else { 796 | var promise = value.$promise; // Save the promise 797 | shallowClearAndCopy(data, value); 798 | value.$promise = promise; // Restore the promise 799 | } 800 | } 801 | response.resource = value; 802 | 803 | return response; 804 | }, function(response) { 805 | (error || noop)(response); 806 | return $q.reject(response); 807 | }); 808 | 809 | promise['finally'](function() { 810 | value.$resolved = true; 811 | if (!isInstanceCall && cancellable) { 812 | value.$cancelRequest = angular.noop; 813 | $timeout.cancel(numericTimeoutPromise); 814 | timeoutDeferred = numericTimeoutPromise = httpConfig.timeout = null; 815 | } 816 | }); 817 | 818 | promise = promise.then( 819 | function(response) { 820 | var value = responseInterceptor(response); 821 | (success || noop)(value, response.headers); 822 | return value; 823 | }, 824 | responseErrorInterceptor); 825 | 826 | if (!isInstanceCall) { 827 | // we are creating instance / collection 828 | // - set the initial promise 829 | // - return the instance / collection 830 | value.$promise = promise; 831 | value.$resolved = false; 832 | if (cancellable) value.$cancelRequest = timeoutDeferred.resolve; 833 | 834 | return value; 835 | } 836 | 837 | // instance call 838 | return promise; 839 | }; 840 | 841 | 842 | Resource.prototype['$' + name] = function(params, success, error) { 843 | if (isFunction(params)) { 844 | error = success; success = params; params = {}; 845 | } 846 | var result = Resource[name].call(this, params, this, success, error); 847 | return result.$promise || result; 848 | }; 849 | }); 850 | 851 | Resource.bind = function(additionalParamDefaults) { 852 | return resourceFactory(url, extend({}, paramDefaults, additionalParamDefaults), actions); 853 | }; 854 | 855 | return Resource; 856 | } 857 | 858 | return resourceFactory; 859 | }]; 860 | }); 861 | 862 | 863 | })(window, window.angular); 864 | --------------------------------------------------------------------------------