Half-Elf on Tech

Thoughts From a Professional Lesbian

Author: Ipstenu (Mika Epstein)

  • Alexa Flash Briefing Skills and Video Enclosures

    Alexa Flash Briefing Skills and Video Enclosures

    One of my goals this year, aided by the inimitable Chris Lema, was to make an Amazon Echo app.

    There’s a lot more to the whole plan, but I want to start with the simple stuff first. So the very first step is that I want to make a “Flash Briefing” app. That will allow people to get the latest posts from my site.

    For the most part, this is trivial. Creating a Flash Briefing Skill is fairly well documented and essentially is this:

    1. Make an account
    2. Create a Skill
    3. Point it to your RSS feed
    4. Give it an icon

    And that works great. Unless, of course, you have videos in a post.

    You see, when I went to add my RSS feed, I got this rather useless error:

    Error: Item [https://example.com/?p=9619] doesn't contain a valid Stream Url. Video Url (if present) must be accompanied with a valid Stream Url in an Item.
    

    What Went Wrong?

    The error was caused by having a video in a post. Now, I need to stress the stupidity here. I have a video inside the post. It’s not a video post, it just has an embedded video because it was contextually needed.

    Logically I googled the error and came up empty. This did not surprise me. I’ve been resigned to learn that Amazon is not actually very helpful with their UX or error messages. I’m not sure why this is but their tech UX, the stuff made for developers not the devices made for end-users, tend to be incredibly poorly designed and ill documented for new people.

    That said, I understood the error was reflecting on a ‘video’ URL, and I had a video in that specific post. I removed the video, tested, and it worked. Ergo the error was caused by the video’s existence. But as it happened, Stream URL had nothing to do with it.

    It Was Elements

    The real issue was found when I read through the feed format details which had mention of a need, for audio content, an “URL specifying the location of audio content for an audio feed.”

    This wasn’t an audio file, but the example for a JSON feed was to include a “streamUrl” value. Oh. And for RSS? An “enclosure element with type attribute set to audio/mpeg”

    This had to be related.

    When I looked at my RSS feed, however, I saw this:

    <enclosure url="https://example.com/path/to/myvideo.mp4" length="3120381" type="video/mp4" />
    

    Wasn’t that what I needed?

    A Second Enclosure

    Apparently the flash briefing RSS code is stupid and thinks that any enclosure has to have the “audio/mpeg” type. So how do I add in this?

    <enclosure url="https://example.com/path/to/myvideo.mp4" length="3120381" type="audio/mpeg" />
    

    By the way yes I reported this to them as a bug. Anyway, the first attempt at fixing this was for me to add a new custom post meta for the enclosure like this:

    
    3120381
    audio/mpeg
    

    That auto-added the proper enclosure code because WordPress knows what it’s doing. Once I was sure that worked, I filed the full bug report and then went the other way.

    Remove The Enclosures

    This is not something I generally recommend. However if you’re not podcasting or vlogging and you have no need to encourage people to download your videos and media via RSS, then you can get away with this:

    function delete_enclosure(){
        return '';
    }
    add_filter( 'do_enclose', 'delete_enclosure' );
    add_filter( 'rss_enclosure', 'delete_enclosure' );
    add_filter( 'atom_enclosure', 'delete_enclosure' );
    

    That removes the enclosure code.

    Build Your Own

    Another fix would have been to make a JSON output or use something like JSONFeed itself. Or of course I could have auto-duplicated the embeds, but that just felt wrong to me.

  • SEO and URLs and Indexes

    SEO and URLs and Indexes

    The question of the day. “Does having all your posts indexed on the main page of your site cause the highest SEO value to be in your main domain name and not the individual posts or categories?”

    No.

    What is your homepage for?

    As a reminder, you don’t have to have all your posts listed on your main page, or any page when you get down to it. When you don’t we call those ‘static sites’ but really what we mean is “A non-newspaper site.”

    Yoast talks about this with regards to what they call homepage SEO. As Michiel notes in that post, the point of your homepage is to load fast, explain the purpose of the site, and direct people to where they need to be.

    Where Is SEO Value?

    The SEO value in your site is not going to be in the homepage or the category pages. It’s not in the archive pages either. The value of your site is found in your important content. We call this your flagship or cornerstone content. Those are the pages you want to drive people to, to get the most out of their visit.

    There’s a lot of good advice about how to make good content like that, from CopyBlogger and Yoast and more. But the point they all make is that the mead and meat part of your site is the content and not the index.

    Do index pages lose SEO?

    Again. No. Look. I get it. The real question is “Will sending everyone to my home page screw up my cornerstone SEO?”

    No. That’s not how it works. If people are looking for “your website topic” then yes, they will end up on the home page. And if your home page is a constantly rotating list of pages, then yes, they will see links to some deeper content.

    But that doesn’t hurt your SEO. Google will rank your cornerstone pages properly because they will rank higher. They will have more specific content. They will be your centers. So spending all your time coming up with fancy ways to get rid of content that is underperforming, hiding it and removing it, it’s just a waste of time and energy. Of course that’s a bit of a different topic.

    Your homepage won’t hurt your SEO

    Listing your recent posts on your home page doesn’t hurt your SEO. Actually it helps a little to have a ‘recent posts’ section. But no, having the posts lists doesn’t hurt the SEO. Your site will be just fine. Don’t make weird CPTs to shuffle things around.

  • FacetWP, Genesis, and Archives

    FacetWP, Genesis, and Archives

    In my ongoing use of FacetWP and Genesis, I ran into a case where I wanted to change the archive description content based on what sorts of options had been selected in the search. In part I wanted to remind visitors of what they’d picked, but also I wanted to easy to remove a search facet.

    Before

    In the beginning, the archive was a static thing:

    Before any work was done - it says 'TV Shows' and lists how many.

    This is intentionally boring. It lists the archive title, how many posts, and a description.

    Filtering the Content

    Since this is Genesis, the first step is to know how to filter at all. Since I’m only doing this on custom post types, I went with the very precise action and that is genesis_do_cpt_archive_title_description (aptly named).

    I remove it and then add in my own:

    remove_action( 'genesis_before_loop', 'genesis_do_cpt_archive_title_description' );
    add_action( 'genesis_before_loop', 'DOMAIN_do_facet_archive_title_description' );
    

    From here out, all the work will happen in the function DOMAIN_do_facet_archive_title_description which lives in my functions.php because it’s all theme specific.

    What Gets Added

    Now it’s time to decide what you want to add. I picked three things:

    1. Change the post count based on the results
    2. List the selections chosen
    3. Change the title based on the sort order

    Those are two simple asks and one weird one.

    Facet comes with the ability to display counts and selections:

    • facetwp_display( 'counts' );
    • facetwp_display( 'selections' );

    The problem I had was that the counts were formatted in a way I didn’t like, so I quickly cleaned it up by filtering the result count:

    add_filter( 'facetwp_result_count', function( $output, $params ) {
        $output = $params['total'];
        return $output;
    }, 10, 2 );
    

    That means the count and the selections can simply be tacked on to the description.

    Adding the Sort Data

    The hardest part was figuring out how to add the sort data. Since FacetWP uses a lot of javascript, I spent half an afternoon ranting to myself and trying to figure out how to do this in javascript. And then I did what I usually do when confused. I read the code.

    As I read, I realized some of FacetWP’s magic is that they pass the GET parameters of the search over to javascript… And if they were doing that, then I could just use PHP to grab those parameters.

    All I had to do was pass $_GET['fwp_sort'] into a variable.

    The Code

    Enough talk. Here’s the code:

    function lwtvg_do_facet_archive_title_description() {
    
    	$headline = genesis_get_cpt_option( 'headline' );
    
    	if ( empty( $headline ) && genesis_a11y( 'headings' ) ) $headline = post_type_archive_title( '', false );
    
    	$intro_text  = genesis_get_cpt_option( 'intro_text' );
    	$count_posts = facetwp_display( 'counts' );
    	$selections  = facetwp_display( 'selections' );
    	$fwp_sort    = ( isset( $_GET['fwp_sort'] ) )? $_GET['fwp_sort'] : '';
    
    	switch ( $fwp_sort ) {
    		case 'most_chars':
    			$sort = 'Number of Characters (Descending)';
    			break;
    		case 'least_chars':
    			$sort = 'Number of Characters (Ascending)';
    			break;
    		case 'most_dead':
    			$sort = 'Number of Dead Characters (Descending)';
    			break;
    		case 'least_dead':
    			$sort = 'Number of Dead Characters (Ascending)';
    			break;
    		case 'date_desc':
    			$sort = 'Date (Newest)';
    			break;
    		case 'date_asc':
    			$sort = 'Date (Oldest)';
    			break;
    		case 'title_desc':
    			$sort = 'Name (Z-A)';
    			break;
    		case 'title_asc':
    		default:
    			$sort = 'Name (A-Z)';
    	}
    
    	$headline    = $headline ? sprintf( '<h1 %s>%s Sorted By %s (%s)</h1>', genesis_attr( 'archive-title' ), strip_tags( $headline ), $sort, $count_posts ) : '';
    
    	$intro_text  = $intro_text ? apply_filters( 'genesis_cpt_archive_intro_text_output', $intro_text ) : '';
    	$intro_text .= $selections;
    
    	if ( $headline || $intro_text ) printf( '<div %s>%s</div>', genesis_attr( 'cpt-archive-description' ), $headline . $intro_text );
    }
    

    You’ll notice that I’ve kept in all the regular Genesis filters. This was so that my theme can take advantage of whatever magic Genesis invents down the line.

    How It Looks

    Now the default looks like this:

    Default view, before sorting

    And after you’ve picked a few options, it changes to this:

    After Sorting

    If you click the little x’s on the side of the selections, they’re removed.

    There’s still room for design improvement, but remember folks. Release and iterate.

  • Adding Sort Options to Facet

    Adding Sort Options to Facet

    As I implement more and more aspects of FacetWP, I find more and more ways to manipulate the searches. At first I only added in the features that let people easily search for multiple aspects at once. But I hadn’t yet added in any features to sorting.

    Sorting and Ordering

    The way Facets generally work is that you can easily organize all ‘types’ together, so if you wanted to search for everything that crossed four separate categories, it was very easy. In addition, you can extend it to search meta data as well.

    Sorting, on the other hand, is changing the order of the results. For example, if you wanted to search for everyone with terms A, B, and D, and post meta foo, but order them based on post meta bar, you can!

    A Practical Example

    I always do better with examples I can wrap my hands around.

    Take television shows. Take a list of 500 TV shows, and have them include the following taxonomies:

    • Genres (drama, sitcom, etc)
    • Airdates (Year to Year)
    • Tropes (common tropes)
    • Number of characters
    • Number of dead characters

    That’s enough for now.

    With that list, and a couple facets, you can concoct a smaller list of all sitcoms that aired in between 2014 and 2016 (inclusive), with a trope of ‘sex workers.’ The answer is 4 by the way. By default, the list displays alphabetically.

    But. What if you wanted to order them by the ones with the most characters first?

    That’s sorting.

    The Code

    Okay so how do we add this in? Functions!

    Facet comes with quite a few defaults, but it lets you add your own sort options. The two things I’m going to show below are how to rename the display labels for some of the defaults, and how to add in one new option for the most number of characters:

    add_filter( 'facetwp_sort_options', 'DOMAIN_facetwp_sort_options', 10, 2 );
    
    function facetwp_sort_options( $options, $params ) {
    
    	$options['default']['label']    = 'Default (Alphabetical)';
    	$options['title_asc']['label']  = 'Name (A-Z)';
    	$options['title_desc']['label'] = 'Name (Z-A)';
    
    	if ( is_post_type_archive( 'DOMAIN_shows' ) ) {
    
    		$options['most_characters'] = array(
    			'label' => 'Number of Characters (Descending)',
    			'query_args' => array(
    				'orderby'  => 'meta_value_num', // sort by numerical
    				'meta_key' => 'DOMAIN_char_count',
    				'order'    => 'DESC', // descending order
    			)
    		);
    	}
    

    I have this wrapped in a check for is_post_type_archive because I don’t want the options to show on other pages. The meta key is the name of the meta key you’re going to use to sort by (I have key that updates every time a post is saved with a count of characters attached) and the orderby value is one of the ones WP Query can use.

    End result?

    A dropdown with the options

    Looks nice!

  • Calm Under Pressure

    Calm Under Pressure

    A friend remarked she was impressed I was able to stay calm under the abuse slung my way. I have a secret.

    I’m Often Very Angry

    I’m not calm. I’m often quite irate and I froth and I rant. Some of my friends hear those rants. The complaints about how can people be that myopic and obtuse run rampant. I also do on occasion see red and feel my blood pressure rise and I want to reply to people so angrily.

    I really do. I want to scream and use all caps to emphasize that lying to people, trying to trick them, or otherwise doing bad things makes them bad people. I really want to shake some people to make them see they’re hurting themselves more than anything else. Some people I want to take their computers away because clearly they’re too immature for even free plugin hosting.

    That’s My Secret

    If you saw the movie The Avengers, then you may recall a moment when Bruce Banner said he controlled the Hulk by always being angry.

    The trick of that is its simplicity. You see, if Banner could only control the Hulk by not getting angry, then he’d lose. But by accepting his anger and being always angry at the state of the world, at his situation, and so on, he doesn’t have to control the anger anymore. He has to control his temper. That is, he controls his response to anger, but he allows the anger to happen.

    It’s Okay To Be Angry

    We all get angry. We see people doing stupid things and we get mad. But we have a choice in how we respond and react to those things. You can’t stop yourself from being angry, but you can stop yourself from being an uncontrolled Hulk when you’re angry.

    Seeing people not care about others gets me madder than anything else. Be it cutting in line, stealing, abuse, or the government. Or, yes, plugins. I get pissed off. I find that lack of humanity, lack of humanitarianism, to be appalling and disgusting.

    But I don’t lash out and hurt people (at least not intentionally) when it happens. I try to educate, to discuss, and to communicate.

    We Have A Choice

    We usually have a choice on how we react. There are, of course, situations where we are not in control of ourselves, where we react before we can control. Trauma triggers cause that in many of us. But where and when we do have a choice, we must remember our humanity. We must chose control.

  • Taxonomy Icons

    Taxonomy Icons

    Last year I talked about how I made icons for my taxonomy terms. When you have a limited number of terms, that makes sense. When you have a taxonomy with the potential for a high volume of terms, like nations (192), or worse an unlimited number of terms, this approach looses its value.

    Instead, I realized what I needed for a particular project was a custom icon for each taxonomy. Not the term.

    I split this up into two files because I used a slightly different setup for my settings API, but the tl;dr of all this is I made a settings page under themes called “Taxonomy Icons” which loads all the public, non-default taxonomies and associates them with an icon.

    For this to work for you, you will need to have your images in a folder and define that as your IMAGE_PATH in the code below. Also mine is using .svg files, so change that if you’re not.

    File 1: taxonomy-icons.php

    The one gotcha with this is I usually set my default values with $this->plugin_vars = array(); in the __construct function. You can’t do that with custom taxonomies, as they don’t exist yet.

    class TaxonomyIcons {
    
    	private $settings;
    	const SETTINGS_KEY = 'taxicons_settings';
    	const IMAGE_PATH   = '/path/to/your/images/';
    
    	/*
    	 * Construct
    	 *
    	 * Actions to happen immediately
    	 */
        public function __construct() {
    
    		add_action( 'admin_menu', array( $this, 'admin_menu' ) );
    		add_action( 'admin_init', array( $this, 'admin_init' ) );
    		add_action( 'init', array( $this, 'init' ) );
    
    		// Normally this array is all the default values
    		// Since we can't set it here, it's a placeholder
    		$this->plugin_vars = array();
    
    		// Create the list of imagess
    		$this->images_array = array();
    		foreach ( glob( static::IMAGE_PATH . '*.svg' ) as $file) {
    			$this->images_array[ basename($file, '.svg') ] = basename($file);
    		}
    
    		// Permissions needed to use this plugin
    		$this->plugin_permission = 'edit_posts';
    
    		// Menus and their titles
    		$this->plugin_menus = array(
    			'taxicons'    => array(
    				'slug'         => 'taxicons',
    				'submenu'      => 'themes.php',
    				'display_name' => __ ( 'Taxonomy Icons', 'taxonomy-icons' ),
    			),
    		);
        }
    
    	/**
    	 * admin_init function.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function admin_init() {
    		// Since we couldn't set it in _construct, we do it here
    		// Create a default (false) for all current taxonomies
    		$taxonomies = get_taxonomies( array( 'public' => true, '_builtin' => false ), 'names', 'and' );
    		if ( $taxonomies && empty( $this->plugin_vars ) ) {
    			foreach ( $taxonomies as $taxonomy ) {
    				$this->plugin_vars[$taxonomy] = false;
    			}
    		}
    	}
    
    	/**
    	 * init function.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function init() {
    		add_shortcode( 'taxonomy-icon', array( $this, 'shortcode' ) );
    	}
    
    	/**
    	 * Get Settings
    	 *
    	 * @access public
    	 * @param bool $force (default: false)
    	 * @return settings array
    	 * @since 0.1.0
    	 */
    	function get_settings( $force = false) {
    		if ( is_null( $this->settings ) || $force ) {
    			$this->settings = get_option( static::SETTINGS_KEY, $this->plugin_vars );
    		}
    		return $this->settings;
    	}
    
    	/**
    	 * Get individual setting
    	 *
    	 * @access public
    	 * @param mixed $key
    	 * @return key value (if available)
    	 * @since 0.1.0
    	 */
    	function get_setting( $key ) {
    		$this->get_settings();
    		if ( isset( $this->settings[$key] ) ) {
    			return $this->settings[$key];
    		} else {
    			return false;
    		}
    	}
    
    	/**
    	 * Set setting from array
    	 *
    	 * @access public
    	 * @param mixed $key
    	 * @param mixed $value
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function set_setting( $key, $value ) {
    		$this->settings[$key] = $value;
    	}
    
    	/**
    	 * Save individual setting
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function save_settings() {
    		update_option( static::SETTINGS_KEY, $this->settings );
    	}
    
    	/**
    	 * admin_menu function.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function admin_menu() {
    
    		foreach ( $this->plugin_menus as $menu ) {
    			$hook_suffixes[ $menu['slug'] ] = add_submenu_page(
    				$menu['submenu'],
    				$menu['display_name'],
    				$menu['display_name'],
    				$this->plugin_permission,
    				$menu['slug'],
    				array( $this, 'render_page' )
    			);
    		}
    
    		foreach ( $hook_suffixes as $hook_suffix ) {
    			add_action( 'load-' . $hook_suffix , array( $this, 'plugin_load' ) );
    		}
    	}
    
    	/**
    	 * Plugin Load
    	 * Tells plugin to handle post requests
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function plugin_load() {
    		$this->handle_post_request();
    	}
    
    	/**
    	 * Handle Post Requests
    	 *
    	 * This saves our settings
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function handle_post_request() {
    		if ( empty( $_POST['action'] ) || 'save' != $_POST['action'] || !current_user_can( 'edit_posts' ) ) return;
    
    		if ( !wp_verify_nonce( $_POST['_wpnonce'], 'taxicons-save-settings' ) ) die( 'Cheating, eh?' );
    
    		$this->get_settings();
    
    		$post_vars = $this->plugin_vars;
    		foreach ( $post_vars as $var => $default ) {
    			if ( !isset( $_POST[$var] ) ) continue;
    			$this->set_setting( $var, sanitize_text_field( $_POST[$var] ) );
    		}
    
    		$this->save_settings();
    	}
    
    	/**
    	 * Render admin settings page
    	 * If setup is not complete, display setup instead.
    	 *
    	 * @access public
    	 * @return void
    	 * @since 0.1.0
    	 */
    	function render_page() {
    		// Not sure why we'd ever end up here, but just in case
    		if ( empty( $_GET['page'] ) ) wp_die( 'Error, page cannot render.' );
    
    		$screen = get_current_screen();
    		$view  = $screen->id;
    
    		$this->render_view( $view );
    	}
    
    	/**
    	 * Render page view
    	 *
    	 * @access public
    	 * @param mixed $view
    	 * @param array $args ( default: array() )
    	 * @return content based on the $view param
    	 * @since 0.1.0
    	 */
    	function render_view( $view, $args = array() ) {
    		extract( $args );
    		include 'view-' . $view . '.php';
    	}
    
    	/**
    	 * Render Taxicon
    	 *
    	 * This outputs the taxonomy icon associated with a specific taxonomy
    	 *
    	 * @access public
    	 * @param mixed $taxonomy
    	 * @return void
    	 */
    	public function render_taxicon ( $taxonomy ) {
    
    		// BAIL: If it's empty, or the taxonomy doesn't exist
    		if ( !$taxonomy || taxonomy_exists( $taxonomy ) == false ) return;
    
    		$filename = $this->get_setting( $taxonomy );
    
    		// BAIL: If the setting is false or otherwise empty
    		if ( $filename == false || !$filename || empty( $filename ) ) return;
    
    		$icon     = file_get_contents( static::IMAGE_PATH . $filename  . '.svg' );
    		$taxicon  = '<span role="img" class="taxonomy-icon ' . $filename . '">' . $icon . '</span>';
    
    		echo $taxicon;
    	}
    
    	/*
    	 * Shortcode
    	 *
    	 * Generate the Taxicon via shortcode
    	 *
    	 * @param array $atts Attributes for the shortcode
    	 *        - tax: The taxonomy
    	 * @return SVG icon of awesomeness
    	 */
    	function shortcode( $atts ) {
    		return $this->render_taxicon( $atts[ 'tax' ] );
    	}
    
    }
    
    new TaxonomyIcons();
    

    File 2: view-appearance_page_taxicons.php

    Why that name? If you look at my render_view function, I pass the ID from get_current_screen() to it, and that means the ID is appearance_page_taxicons and that’s the page name.

    <div class="wrap">
    
    	<h1><?php _e( 'Taxonomy Icons', 'taxonomy-icons' ); ?></h1>
    
    	<div>
    
    		<p><?php __( 'Taxonomy Icons allows you to assign an icon to a non-default taxonomy in order to make it look damn awesome.', 'taxonomy-icons' ); ?></p>
    
    		<p><?php __( 'By default, Taxonomy Icons don\'t display in your theme. In order to use them, you can use a shortcode or a function:' , 'taxonomy-icons' ); ?></p>
    
    		<ul>
    			<li>Shortcode: <code>[taxonomy-icon tax=TAXONOMY]</code></li>
    		</ul>
    
    		<form method="post">
    
    		<?php
    		if ( isset( $_GET['updated'] ) ) {
    			?>
    			<div class="notice notice-success is-dismissible"><p><?php _e( 'Settings saved.', 'taxonomy-icons' ); ?></p></div>
    			<?php
    		}
    		?>
    
    		<input type="hidden" name="action" value="save" />
    		<?php wp_nonce_field( 'taxicons-save-settings' ) ?>
    
    		<table class="form-table">
    
    			<tr>
    				<th scope="row"><?php _e( 'Category', 'taxonomy-icons' ); ?></th>
    				<th scope="row"><?php _e( 'Current Icon', 'taxonomy-icons' ); ?></th>
    				<th scope="row"><?php _e( 'Select Icon', 'taxonomy-icons' ); ?></th>
    			</tr>
    
    			<?php
    
    			foreach ( $this->plugin_vars as $taxonomy => $value ) {
    				?>
    				<tr>
    					<td>
    						<strong><?php echo get_taxonomy( $taxonomy )->label; ?></strong>
    						<br /><em><?php echo get_taxonomy( $taxonomy )->name; ?></em>
    					</td>
    
    					<td>
    						<?php
    						if ( $this->get_setting( $taxonomy ) && $this->get_setting( $taxonomy ) !== false ) {
    							echo $this->render_taxicon( $taxonomy );
    						}
    						?>
    
    					</td>
    
    					<td>
    						<select name="<?php echo $taxonomy; ?>" class="taxonomy-icon">
    							<option value="">-- <?php _e( 'Select an Icon', 'taxonomy-icons' ); ?> --</option>
    							<?php
    							foreach ( $this->images_array as $file => $name ) {
    								?><option value="<?php echo esc_attr( $file ); ?>" <?php echo $file == $this->get_setting( $taxonomy ) ? 'selected="selected"' : ''; ?>><?php echo esc_html( $name ); ?></option><?php
    								};
    							?>
    						</select>
    					</td>
    
    				</tr><?php
    			}
    
    			?>
    
    			<tr valign="top">
    				<td colspan="3">
    					<button type="submit" class="button button-primary"><?php _e( 'Save', 'taxonomy-icons' ); ?></button>
    				</td>
    			</tr>
    		</table>
    		</form>
    	</div>
    </div>
    

    End Result

    And in the end?

    An example of Taxonomy Icons

    By the way, the image has some wrong text, but that is what it looks like.