Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Best practices for WordPress plugin development - PHPWorld

Best practices for WordPress plugin development - PHPWorld

Presented on November 17th 2015 at PHPWorld, Washington D.C., USA.
https://world.phparch.com/
---------------------------------------------------------------
The WordPress plugin system allows you to add functionality to WordPress in a snap and turn it into much more than "just a blogging platform." Custom post types, post formats, shortcodes, custom fields, and metaboxes are all ways to extend the functionality of WordPress. In this tutorial, we'll look at a number of best practices for building WordPress plugins and some common mistakes made. Bring your favorite plugin to improve, or start building your own.

Juliette Reinders Folmer

November 17, 2015
Tweet

More Decks by Juliette Reinders Folmer

Other Decks in Programming

Transcript

  1. Best Practices for
    WordPress Plugin Development
    Tutorial by Juliette Reinders Folmer / @jrf_nl

    View Slide

  2. #phpworld

    View Slide

  3. Who Are You ?

    View Slide

  4. #phpworld
    Who Am I ?
    Self-employed,
    Independent
    Consultant
    Creator
    PHPCheatsheets
    phpcheatsheets.com
    Frequent
    Contributor to
    Open Source
    Projects

    View Slide

  5. #phpworld
    Agenda
    Introducing Best
    Practices
    Applying
    Best Practices
    Closing

    View Slide

  6. #phpworld
    Agenda
    Introducing Best
    Practices

    View Slide

  7. WordPress Basics

    View Slide

  8. #phpworld
    Anatomy of WordPress

    View Slide

  9. #phpworld
    Page
    footer
    Admin
    Bar
    Page
    content
    (loop)
    Page
    header
    (Invisible) HTML
    Sidebar
    containing
    widgets
    (Main)
    menu

    View Slide

  10. #phpworld
    Admin Bar
    (Invisible) HTML
    Admin
    footer
    Admin
    Menu
    1. Post
    types
    2. Custom-
    izations
    3. Extras
    Admin
    page
    (with
    dashboard
    widgets)

    View Slide

  11. #phpworld
    Anatomy of WordPress
    Functionality
     Core
     Plugins
     Themes
     Languages
     Js Libraries
    Content
     Post Types
     Taxonomies
     Widgets
     Users
     Meta data/custom fields
     Options
     Shortcodes
     OEmbeds

    View Slide

  12. #phpworld
    Hooks

    View Slide

  13. #phpworld
    Hooks
    See:
     WordPress Codex & Developer Reference
     Hooks database: http://adambrown.info/p/wp_hooks
     Debug Bar – Action & filter hooks plugin

    View Slide

  14. #phpworld
    Hooking into WP
    apply_filter( 'hook_name', 'function_name',
    $priority = 10, $accepted_args = 1 );
    add_action( 'hook_name', 'function_name',
    $priority = 10, $accepted_args = 1 );
    add_action( 'hook_name', array( $this, 'method_name' ),
    $priority = 10, $accepted_args = 1 );
    add_action( 'hook_name',
    array( __CLASS__, 'static_method_name' ),
    $priority = 10, $accepted_args = 1 );

    View Slide

  15. #phpworld
    Action Hooks Front-end
    muplugins
    _loaded
    plugins
    _loaded
    setup
    _theme
    set_current
    _user
    after_setup
    _theme
    init wp_loaded
    parse
    _request
    posts
    _selection
    wp
    wp_head the_post wp_meta wp_footer
    admin_bar
    _menu

    View Slide

  16. #phpworld
    Action Hooks Back-end
    muplugins
    _loaded
    plugins
    _loaded
    setup
    _theme
    set_current
    _user
    after_setup
    _theme
    init wp_loaded
    admin
    _menu
    admin_init
    current
    _screen
    load-
    {page}
    posts
    _selection
    wp
    admin
    _head
    admin_bar
    _menu
    admin
    _notices
    the_post
    admin
    _footer

    View Slide

  17. #phpworld
    Action Hooks Back-end
    muplugins
    _loaded
    plugins
    _loaded
    setup
    _theme
    set_current
    _user
    after_setup
    _theme
    init wp_loaded
    admin
    _menu
    admin_init
    current
    _screen
    load-
    {page}
    posts
    _selection
    wp
    admin
    _head
    admin_bar
    _menu
    admin
    _notices
    the_post
    admin
    _footer

    View Slide

  18. #phpworld
    The Loop

    View Slide

  19. #phpworld
    The Loop
    if ( have_posts() ) :
    while ( have_posts() ) : the_post();
    //
    // Post Content here
    // the_title()
    // the_content()
    // the_permalink()
    // ...
    endwhile;
    endif;

    View Slide

  20. #phpworld
    Putting the
    Pieces Together

    View Slide

  21. #phpworld
    Putting the Pieces Together
    wp-config.php
    Must-use plugins
    Plugins
    • [MS] Network-activated plugins
    • Site-activated plugins
    Theme
    • Child-theme functions.php
    • Parent-theme functions.php

    View Slide

  22. Doing It Wrong ™

    View Slide

  23. #phpworld
    Doing It Wrong
    function wrap_content( $content ) {
    echo '' .
    $content . '';
    }
    add_filter( 'the_content', 'wrap_content' );

    View Slide

  24. #phpworld
    Doing It Right
    function my_prefix_wrap_content( $content ) {
    return '' .
    $content . '';
    }
    add_filter( 'the_content',
    'my_prefix_wrap_content' );

    View Slide

  25. #phpworld
    Doing It Wrong
    function prefix_add_hooks() {
    add_action( 'init', 'prefix_my_init' );
    add_action( 'admin_init', 'prefix_my_admin_init' );
    }
    add_action( 'wp', 'prefix_add_hooks' );

    View Slide

  26. #phpworld
    Doing It Right
    function prefix_add_hooks() {
    add_action( 'init', 'prefix_my_init' );
    add_action( 'admin_init', 'prefix_my_admin_init' );
    }
    add_action( 'plugins_loaded', 'prefix_add_hooks' );

    View Slide

  27. #phpworld
    Doing It Wrong
    function unique_prefix_content( $content ) {
    return $content .
    'Copyright Company 2010-' .
    date( 'Y' ) .
    '.';
    }
    add_filter( 'the_content', 'unique_prefix_content' );

    View Slide

  28. #phpworld
    Doing It Right
    function unique_prefix_content( $content ) {
    return $content . '' .
    sprintf(
    /* Translators: 1: Company Name, 2: Year. */
    esc_html__( 'Copyright %1$s %2$s.', 'text-domain' ),
    'Company',
    date_i18n( 'Y', get_the_date( 'U' ) )
    ) .
    '';
    }
    add_filter( 'the_content', 'unique_prefix_content' );

    View Slide

  29. #phpworld
    Doing It Wrong
    function unique_prefix_title( $title, $id ) {
    $post = get_post( $id );
    if ( $post->post_type == 'my_cpt' ) {
    $title = sprintf(
    esc_html__( '%s (My Custom Type)', 'text-domain' ),
    $title
    );
    }
    return $title;
    }
    add_filter( 'the_title', 'unique_prefix_title' );

    View Slide

  30. #phpworld
    Doing It Right
    function unique_prefix_title( $title, $id ) {
    $post = get_post( $id );
    if ( ! empty( $post->post_type ) &&
    'my_cpt' === $post->post_type ) {
    $title = sprintf(
    esc_html__( '%s (My Custom Type)', 'text-domain' ),
    $title
    );
    }
    return $title;
    }
    add_filter( 'the_title', 'unique_prefix_title', 10, 2 );

    View Slide

  31. #phpworld
    Doing It Wrong
    public function add_settings_link( $links, $file ) {
    if ( plugin_basename( __FILE__ ) === $file ) {
    $mylinks = array(
    'Settings',
    );
    $links = array_merge( $links, $mylinks );
    }
    return $links;
    }
    add_filter( 'plugin_action_links',
    array( $this, 'add_settings_link' ), 10, 2 );

    View Slide

  32. #phpworld
    Doing It Right
    public function add_settings_link( $links ) {
    if ( current_user_can( 'manage_options' ) ) {
    $links[] = sprintf(
    '%3$s',
    esc_url( $this->settings_url ),
    esc_attr__( 'Plugin-name Settings', 'text-d..n'),
    esc_html__( 'Settings', 'text-domain' ) );
    }
    return $links;
    }
    add_filter(
    'plugin_action_links_' . plugin_basename( __FILE__ ),
    array( $this, 'add_settings_link' ) );

    View Slide

  33. #phpworld
    Doing It Wrong
    function my_prefix_scripts() {
    echo '
    3.0/jquery.min.js">
    plugin/js/my-jquery-dependant-script.js' ) .
    '">';
    }
    add_action( 'wp_head', 'my_prefix_scripts' );

    View Slide

  34. #phpworld
    Doing It Right
    function my_prefix_scripts() {
    $suffix = ( ( defined( 'SCRIPT_DEBUG' ) &&
    true === SCRIPT_DEBUG ) ? '' : '.min' );
    wp_enqueue_script(
    'my-plugin-js', // ID.
    plugins_url( 'js/my-script' . $suffix . '.js', __FILE__
    ), // URL.
    array( 'jquery' ), // Dependants.
    PLUGIN_VERSION, // Version.
    true // Load in footer ?
    );
    }
    add_action( 'wp_enqueue_scripts', 'my_prefix_scripts' );

    View Slide

  35. #phpworld
    Doing It Wrong
    public function filter_featured_image( $value, $object_id,
    $meta_key, $single ) {
    if ( $meta_key === '_thumbnail_id' && is_home() ) {
    $field = 'frontpageimage';
    $meta_value = get_post_meta( $object_id, $field, $single );
    $value = (int) $meta_value;
    }
    return $value;
    }
    add_filter( 'get_post_metadata',
    array( $this, 'filter_featured_image' ), 10, 4 );

    View Slide

  36. #phpworld
    Doing It Right
    public function filter_featured_image( $value, $object_id, $meta_key ) {
    if ( $meta_key === '_thumbnail_id' && is_home() ) {
    $field = 'frontpageimage';
    remove_filter( 'get_post_metadata',
    array( $this, 'filter_featured_image' ), 10 );
    $meta_value = get_post_meta( $object_id, $field, true );
    add_filter( 'get_post_metadata',
    array( $this, 'filter_featured_image' ), 10, 3 );
    if ( is_numeric( $meta_value ) && 0 < (int) $meta_value ) {
    $value = (int) $meta_value;
    } }
    return $value;
    }
    add_filter( 'get_post_metadata', array( $this, 'filter_featured_image' ),
    10, 3 );

    View Slide

  37. #phpworld
    Doing It Wrong
    function schedule_my_cron_job() {
    wp_schedule_event(
    time(),
    'hourly',
    'my_cron_function‘
    );
    }
    add_action( 'init', 'schedule_my_cron_job' );

    View Slide

  38. #phpworld
    Doing It Right
    function schedule_my_cron_job() {
    if ( ! wp_next_scheduled( 'my_cron_function' ) ) {
    wp_schedule_event(
    time(),
    'hourly',
    'my_cron_function'
    );
    }
    }
    register_activation_hook( __FILE__ ,
    schedule_my_cron_job' );

    View Slide

  39. Best Practices

    View Slide

  40. #phpworld
    Know Your Hooks

    View Slide

  41. #phpworld
    Know Your Hooks
    Hook
    Order
    Actions
    vs
    Filters
    Parameters Priority

    View Slide

  42. #phpworld
    Don’t Reinvent the Wheel

    View Slide

  43. #phpworld
    Don’t Reinvent the Wheel
    Dashboard
    Widgets API
    Database API HTTP API
    File Header
    API
    Filesystem
    API
    Heartbeat API Metadata API Options API Plugin API Quicktags API
    REST API * Rewrite API Settings API Shortcode API
    Theme
    modification
    API
    Theme
    customization
    API
    Transients API Widgets API
    XML-RPC
    WordPress API
    * Expected in WP 4.4

    View Slide

  44. #phpworld
    Use WP Functions
    PHP
     mysqli_...()
     file_put_contents()
     json_encode()
     mail()
     unserialize()
     htmlspecialchars()
     add/stripslashes()
     strtotime() / date()
     http_build_query()
     $_SERVER['HTTPS']
    ...
    WP
     $wpdb->....()
     $wp_filesystem->put_contents()
     wp_json_encode()
     wp_mail()
     maybe_unserialize()
     esc_html()
     wp_unslash()
     mysql2date() / current_time()
     add_query_arg()
     is_ssl()
    ...
    (I mean it!)

    View Slide

  45. #phpworld
    Avoid Conflict

    View Slide

  46. #phpworld
    Avoid Conflict
    function_exists() class_exists()
    Jquery no
    conflicts mode
    Use bundled
    libraries

    View Slide

  47. #phpworld
    Libraries shipped with WP
    json2 /wp-includes/js/json2.js
    underscore /wp-includes/js/underscore.js
    backbone /wp-includes/js/backbone.js
    prototype // via googleapis.com
    scriptaculous-root // via googleapis.com
    scriptaculous-builder // via googleapis.com
    scriptaculous-dragdrop // via googleapis.com
    scriptaculous-effects // via googleapis.com
    scriptaculous-slider // via googleapis.com
    scriptaculous-sound // via googleapis.com
    scriptaculous-controls // via googleapis.com
    cropper /wp-includes/js/crop/cropper.js
    swfobject /wp-includes/js/swfobject.js
    plupload /wp-includes/js/plupload/plupload.full.min.js
    plupload-handlers /wp-includes/js/plupload/handlers.js
    wp-plupload /wp-includes/js/plupload/wp-plupload.js
    swfupload /wp-includes/js/swfupload/swfupload.js
    swfupload-swfobject /wp-includes/js/swfupload/plugins/swfupload.swfobject.js
    swfupload-queue /wp-includes/js/swfupload/plugins/swfupload.queue.js
    swfupload-speed /wp-includes/js/swfupload/plugins/swfupload.speed.js
    swfupload-handlers /wp-includes/js/swfupload/handlers.js
    comment-reply /wp-includes/js/comment-reply.js
    quicktags /wp-includes/js/quicktags.js
    colorpicker /wp-includes/js/colorpicker.js
    editor /wp-admin/js/editor.js
    wp-ajax-response /wp-includes/js/wp-ajax-response.js
    wp-util /wp-includes/js/wp-util.js
    wp-backbone /wp-includes/js/wp-backbone.js
    revisions /wp-admin/js/revisions.js
    imgareaselect /wp-includes/js/imgareaselect/jquery.imgareaselect.js
    mediaelement /wp-includes/js/mediaelement/mediaelement-....js
    wp-mediaelement /wp-includes/js/mediaelement/wp-mediaelement.js
    froogaloop /wp-includes/js/mediaelement/froogaloop.min.js
    wp-playlist /wp-includes/js/mediaelement/wp-playlist.js
    zxcvbn-async /wp-includes/js/zxcvbn-async.js
    password-strength-meter /wp-admin/js/password-strength-meter.js
    jquery-core /wp-includes/js/jquery/jquery.js
    jquery-migrate /wp-includes/js/jquery/jquery-migrate.js
    jquery-ui-core /wp-includes/js/jquery/ui/core.js
    jquery-effects-core /wp-includes/js/jquery/ui/effect.js
    jquery-effects-blind /wp-includes/js/jquery/ui/effect-blind.js
    jquery-effects-bounce /wp-includes/js/jquery/ui/effect-bounce.js
    jquery-effects-clip /wp-includes/js/jquery/ui/effect-clip.js
    jquery-effects-drop /wp-includes/js/jquery/ui/effect-drop.js
    jquery-effects-explode /wp-includes/js/jquery/ui/effect-explode.js
    jquery-effects-fade /wp-includes/js/jquery/ui/effect-fade.js
    jquery-effects-fold /wp-includes/js/jquery/ui/effect-fold.js
    jquery-effects-highlight /wp-includes/js/jquery/ui/effect-highlight.js
    jquery-effects-puff /wp-includes/js/jquery/ui/effect-puff.js
    jquery-effects-pulsate /wp-includes/js/jquery/ui/effect-pulsate.js
    jquery-effects-scale /wp-includes/js/jquery/ui/effect-scale.js
    jquery-effects-shake /wp-includes/js/jquery/ui/effect-shake.js
    jquery-effects-size /wp-includes/js/jquery/ui/effect-size.js
    jquery-effects-slide /wp-includes/js/jquery/ui/effect-slide.js
    jquery-effects-transfer /wp-includes/js/jquery/ui/effect-transfer.js
    jquery-ui-accordion /wp-includes/js/jquery/ui/accordion.js
    jquery-ui-autocomplete /wp-includes/js/jquery/ui/autocomplete.js
    jquery-ui-button /wp-includes/js/jquery/ui/button.js
    jquery-ui-datepicker /wp-includes/js/jquery/ui/datepicker.js
    jquery-ui-dialog /wp-includes/js/jquery/ui/dialog.js
    jquery-ui-draggable /wp-includes/js/jquery/ui/draggable.js
    jquery-ui-droppable /wp-includes/js/jquery/ui/droppable.js
    jquery-ui-menu /wp-includes/js/jquery/ui/menu.js
    jquery-ui-mouse /wp-includes/js/jquery/ui/mouse.js
    jquery-ui-position /wp-includes/js/jquery/ui/position.js
    jquery-ui-progressbar /wp-includes/js/jquery/ui/progressbar.js
    jquery-ui-resizable /wp-includes/js/jquery/ui/resizable.js
    jquery-ui-selectable /wp-includes/js/jquery/ui/selectable.js
    jquery-ui-selectmenu /wp-includes/js/jquery/ui/selectmenu.js
    jquery-ui-slider /wp-includes/js/jquery/ui/slider.js
    jquery-ui-sortable /wp-includes/js/jquery/ui/sortable.js
    jquery-ui-spinner /wp-includes/js/jquery/ui/spinner.js

    View Slide

  48. #phpworld
    Be Unique

    View Slide

  49. #phpworld
    Be Unique
     PHP:
    • Classes
    • Functions
    • global vars
    • (global) constants
     WP:
    • shortcodes
    • option(s) / meta fields
    • nonces
    • settings pages
    • custom post types
    • hooks
     Filenames  HTML/CSS:
    • classes, ids
     Javascript:
    • I18n object
    • functions
     Multi-lingual
    • I18n text
    domain
    Choose your plugin name carefully & implement consistently

    View Slide

  50. #phpworld
    Be Lazy, Be Lean

    View Slide

  51. #phpworld
    Be Lazy, Be Lean
    is_...()
    functions
    Conditional
    loading
    Hook in Include files css / js
    Minify

    View Slide

  52. #phpworld
    Be Safe

    View Slide

  53. #phpworld
    Be Safe
    Check early &
    check often
    current_user_can()
    Validation
    all input
    sanitize_text_field(), sanitize_title(),
    sanitize_meta(), sanitize_user() etc
    Prepare all
    queries
    $wpdb->prepare()
    Escape all
    output
    wp_kses(), esc_html(), esc_attr(),
    esc_url(), esc_textarea(), esc_js() etc
    Use
    wp_nonce
    wp_create_nonce(), wp_nonce_url(),
    wp_nonce_field(), wp_verify_nonce(),
    check_admin_referer(), check_ajax_referer()

    View Slide

  54. #phpworld
    Be Wordly

    View Slide

  55. #phpworld
    Be Worldly
     GetText
    load_plugin_textdomain( 'my_plugin', false,
    plugin_basename( dirname( __FILE__ ) ) . '/languages' );
    wp_kses_post( sprintf(
    __( 'Post updated. View post',
    'my_plugin' ),
    esc_url( get_permalink( $post_ID ) )
    ) );
    esc_html__( 'Custom field updated.', 'my_plugin' );

    View Slide

  56. #phpworld
    Be Worldly
     GetText
    load_plugin_textdomain( 'my_plugin', false,
    plugin_basename( dirname( __FILE__ ) ) . '/languages' );
    wp_kses_post( sprintf(
    __( 'Post updated. View post',
    'my_plugin' ),
    esc_url( get_permalink( $post_ID ) )
    ) );
    esc_html__( 'Custom field updated.', 'my_plugin' );

    View Slide

  57. #phpworld
    Be Worldly
     GetText
     Not only UTF-8
    seems_utf8( $str )
    wp_check_invalid_utf8( $string, $strip = false )
    utf8_uri_encode( $utf8_string, $length = 0 )
    convert_chars( $content )

    View Slide

  58. #phpworld
    I18n Quiz Time
    http://is.gd/WRLP9C
    (https://developer.wordpress.com/2015/04/23/
    wordpress-developers-test-your-i18n-
    internationalization-knowledge/)

    View Slide

  59. #phpworld
    Let Others Hook In

    View Slide

  60. #phpworld
    Let Others Hook In
    do_action( 'my_unique_action_hook', $var_to_pass );
    $filtered_var = apply_filters(
    'my_unique_filter_hook',
    $var_to_pass,
    $context_var,
    $another_context_var
    );

    View Slide

  61. #phpworld
    Make it Beautiful

    View Slide

  62. #phpworld

    View Slide

  63. Developing & Debugging

    View Slide

  64. #phpworld
    Don’t Get Discouraged
    WP_DEBUG
    Error
    logging!
    JS
    console
    logging
    set_
    transient()

    View Slide

  65. #phpworld
    Useful Plugins
    Debug Bar +
    Extensions
    Developer Pig Latin
    User Switching
    Log
    Deprecated
    Notices
    What’s
    Running
    Demo Data Theme Check RTL Tester

    View Slide

  66. #phpworld
    Helpful Infrastructure
    WordPress Coding Standards CS
    WP Unit Testing Framework
    Varying Vagrant Vagrants
    WP CLI
    Generate WP
    WP Gear
    WPackagist vs TGMPA

    View Slide

  67. #phpworld

    View Slide

  68. #phpworld
    Agenda
    Applying
    Best Practices

    View Slide

  69. Let’s Make Things Better

    View Slide

  70. #phpworld


    To be prepared is half
    the victory.
    - Miguel de Cervantes

    View Slide

  71. #phpworld
    Let’s Make Things Better
    Copyrighted
    Post
    Easy Bootstrap
    Shortcode
    Login Logout Search Meter

    View Slide

  72. #phpworld
    Useful Resources
    Codex
    Developer
    Reference
    Source code
    (collection of
    best practices)
    Hook
    database
    Demo Quotes
    plugin
    (including
    handbooks!)

    View Slide

  73. #phpworld
    Agenda
    Closing

    View Slide

  74. Stay Involved

    View Slide

  75. #phpworld
    Help someone who’s
    struggling

    View Slide

  76. #phpworld
    Find an abandoned project

    View Slide

  77. #phpworld
    Have fun!

    View Slide

  78. #phpworld
    Image Credits
     WordPress - mkhmarketing (crayons)
    http://www.flickr.com/photos/mkhmarketing/8469030267/
     Billie Holiday – William P. Gottlieb
    http://lcweb2.loc.gov/diglib/ihas/loc.natlib.gottlieb.04251/default.html
     Anatomy - Eva di Martino
    http://www.pureblacklove.com
     Hooks - Raul Lieberwirth
    http://www.flickr.com/photos/lanier67/185311136/
     Loop - Gabe Kinsman
    http://www.flickr.com/photos/auguris/5286612308/
     Dominos – George Hodan
    http://www.publicdomainpictures.net/view-image.php?image=30522&picture=domino
     Hooks - Melissa Maples
    http://www.flickr.com/photos/melissamaples/3093966940/
     Wheel - Pauline Mak
    http://www.flickr.com/photos/__my__photos/5025541044/
     Conflict - Asaf Antman
    http://www.flickr.com/photos/asafantman/5134136997/
     Unique - Luca Volpi (leafs)
    http://www.flickr.com/photos/luca_volpi/2974346674/
     Lazy - Kevin Cauchi
    http://www.flickr.com/photos/kpcauchi/5376768095/

    View Slide

  79. #phpworld
    Image Credits
     Security – kismihok
    http://www.flickr.com/photos/kismihok/9686252463/
     World - Kenneth Lu
    http://www.flickr.com/photos/toasty/1540997910/
     Hooks – Macroman (red background)
    http://www.flickr.com/photos/macroman/34644959/
     Daisies - Steve Wall
    http://www.flickr.com/photos/stevewall/4780035332/
     Alone – Jon
    http://www.flickr.com/photos/jb-london/3594171841/
     Breaktime - Iryna Yeroshko
    https://www.flickr.com/photos/mandarina94/6389984357/
     Help - Green Kozi
    http://www.flickr.com/photos/themacinator/3445776069/
     Bike - Pauline Mak
    http://www.flickr.com/photos/__my__photos/6399028713/
     Fun - Justin Beckley
    http://www.flickr.com/photos/justinbeckleyphotography/8452437969/
     Logos used are the property of and may be trademarked by their
    respective organizations.

    View Slide

  80. #phpworld

    View Slide

  81. Thank You!
    Slides: http://speakerdeck.com/jrf
    Feedback: https://joind.in/14754
    Contact me: @jrf_nl
    jrf

    View Slide