Files
Projects-Portfolio/projects-portfolio.php
T
Keith Solomon 60db82e459
Release / Build & publish plugin zip (push) Successful in 7s
Render social-share icons as <img> SVG data URIs
Inline <svg> elements render with their default width/height in browsers
even when 'style' and width/height attributes are set (a known quirk
in mixed HTML5/SVG content with some CSS specificity scenarios). Switched
to <img src='data:image/svg+xml,...'> instead. This is universally well-
supported, sizes via standard <img> CSS rules (no namespace/parser edge
cases), and the inline 'width=20 height=20 style=width:20px;height:20px'
on <img> reliably applies.

Added 'img' and 'style' to the kses allowed list so wp_kses() preserves
the data: URI src and the inline styles.
2026-08-12 12:08:24 -05:00

457 lines
16 KiB
PHP

<?php
/**
* The plugin bootstrap file
*
* @link https://keithsolomon.net
* @since 1.0.0
* @package Projects_Portfolio
*
* @wordpress-plugin
*
* Plugin Name: Projects Portfolio
* Description: Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.
* Plugin URI: https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio
* Version: 1.1.9
* Author: Keith Solomon
* Author URI: https://keithsolomon.net
* License: GPL-2.0+
* License URI: http://www.gnu.org/licenses/gpl-2.0.txt
* Text Domain: projects-wp
* Domain Path: /languages
* Update URI: https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio
*/
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
require 'includes/plugin-update-checker/plugin-update-checker.php';
use YahnisElsts\PluginUpdateChecker\v5\PucFactory;
$myUpdateChecker = PucFactory::buildUpdateChecker(
'https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio',
__FILE__,
'projects-portfolio'
);
// Note: PUC's setBranch() is only available when the host is recognized as a
// VCS provider (GitHub, GitLab, Bitbucket). The Gitea URL here falls back to
// PUC's plain JSON metadata mode, so we skip the call rather than throw.
// Current plugin version.
define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.9' );
// Add the required files.
require 'admin/admin-settings.php';
require 'admin/cpt-taxonomy.php';
require 'admin/metabox.php';
require 'includes/helper-functions.php';
require 'includes/providers/interface-repository-provider.php';
require 'includes/providers/class-github-provider.php';
require 'includes/providers/class-gitea-provider.php';
require 'includes/providers/class-provider-factory.php';
/**
* Flush rewrite rules on activation.
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_flush_rewrite_rules() {
projects_portfolio_register_cpt_and_taxonomy();
projects_portfolio_add_rewrite_rules();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'projects_portfolio_flush_rewrite_rules' );
/**
* Add rewrite rules for download endpoint.
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_add_rewrite_rules() {
add_rewrite_rule(
'^download/([0-9]+)/?$',
'index.php?project_download_id=$matches[1]',
'top'
);
}
add_action( 'init', 'projects_portfolio_add_rewrite_rules' );
/**
* WP_Query Vars
*
* @param array $vars Query variables array.
*
* @since 1.0.0
* @return array
*/
function projects_portfolio_query_vars( $vars ) {
$vars[] = 'project_download_id';
return $vars;
}
add_filter( 'query_vars', 'projects_portfolio_query_vars' );
/**
* Handle Download Redirect and Increment Download Count
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_handle_download_redirect() {
$project_id = get_query_var( 'project_download_id' );
if ( $project_id ) {
$download_url = projects_portfolio_get_release_url( (int) $project_id );
if ( $download_url ) {
// Increment download count.
$download_count = (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true );
++$download_count;
update_post_meta( $project_id, '_projects_portfolio_download_count', $download_count );
// Redirect to the release ZIP on the project's host.
// Use wp_redirect() (not wp_safe_redirect()) because the destination
// is an external host the user has explicitly configured — wp_safe_redirect
// would reject it and fall back to admin_url().
wp_redirect( esc_url_raw( $download_url ), 302 ); // phpcs:ignore
exit;
} else {
wp_die(
esc_html__( 'Invalid download URL. Please check the repository URL.', 'projects-wp' ),
esc_html__( 'Download Error', 'projects-wp' ),
array( 'response' => 404 )
);
}
}
}
add_action( 'template_redirect', 'projects_portfolio_handle_download_redirect' );
/**
* Add Download Count Column to Projects Admin Table
*
* @since 1.0.0
* @param array $columns The columns array.
* @return mixed
*/
function projects_portfolio_add_download_count_column( $columns ) {
$columns['download_count'] = esc_html__( 'Download Count', 'projects-wp' );
return $columns;
}
add_filter( 'manage_projects_posts_columns', 'projects_portfolio_add_download_count_column' );
/**
* Display Download Count in Admin Column
*
* @since 1.0.0
* @param string $column The column name.
* @param int $post_id The post ID.
* @return void
*/
function projects_portfolio_display_download_count_column( $column, $post_id ) {
if ( 'download_count' === $column ) {
$download_count = (int) get_post_meta( $post_id, '_projects_portfolio_download_count', true );
echo $download_count ? esc_html( $download_count ) : esc_html__( '0', 'projects-wp' );
}
}
add_action( 'manage_projects_posts_custom_column', 'projects_portfolio_display_download_count_column', 10, 2 );
/**
* Make the Download Count Column Sortable
*
* @since 1.0.0
* @param array $columns The columns array.
* @return mixed
*/
function projects_portfolio_sortable_columns( $columns ) {
$columns['download_count'] = 'download_count';
return $columns;
}
add_filter( 'manage_edit-projects_sortable_columns', 'projects_portfolio_sortable_columns' );
/**
* Handle Sorting for Download Count
*
* @since 1.0.0
* @param WP_Query $query The query object.
* @return void
*/
function projects_portfolio_sort_download_count_column( $query ) {
if ( ! is_admin() || ! $query->is_main_query() ) {
return;
}
if ( 'download_count' === $query->get( 'orderby' ) ) {
$query->set( 'meta_key', '_projects_portfolio_download_count' );
$query->set( 'orderby', 'meta_value_num' );
}
}
add_action( 'pre_get_posts', 'projects_portfolio_sort_download_count_column' );
/**
* Register custom REST API endpoint for projects.
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_register_custom_endpoint() {
register_rest_route(
'projects/v1',
'/projects',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'projects_portfolio_get_projects_data',
'permission_callback' => '__return_true',
)
);
}
add_action( 'rest_api_init', 'projects_portfolio_register_custom_endpoint' );
/**
* Load custom templates for single and archive project views.
*
* @since 1.0.0
* @param string $template The template file path.
* @return mixed
*/
function projects_portfolio_template_loader( $template ) {
if ( is_singular( 'projects' ) ) {
// Check for a single-projects.php template in the theme
$theme_template = locate_template( 'single-projects.php' );
return $theme_template ? $theme_template : plugin_dir_path( __FILE__ ) . 'templates/single-projects.php';
}
if ( is_post_type_archive( 'projects' ) ) {
// Check for an archive-projects.php template in the theme
$theme_template = locate_template( 'archive-projects.php' );
return $theme_template ? $theme_template : plugin_dir_path( __FILE__ ) . 'templates/archive-projects.php';
}
if ( is_tax( 'project-type' ) ) {
// Check for a taxonomy-project-type.php template in the theme
$theme_template = locate_template( 'archive-project-type.php' );
return $theme_template ? $theme_template : plugin_dir_path( __FILE__ ) . 'templates/taxonomy-project-type.php';
}
return $template;
}
add_filter( 'template_include', 'projects_portfolio_template_loader' );
/**
* Enqueue styles for project single and archive templates.
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_enqueue_project_styles() {
if ( is_singular( 'projects' ) || is_post_type_archive( 'projects' ) || is_tax( 'project-type' ) ) {
$is_fse_theme = wp_theme_has_theme_json() && function_exists( 'wp_is_block_theme' ) && wp_is_block_theme();
// Register stylesheet for both theme types
wp_register_style(
'projects-wp-styles',
plugin_dir_url( __FILE__ ) . 'assets/css/style.css',
array(),
PROJECTS_PORTFOLIO_VERSION
);
if ( $is_fse_theme ) {
// Inline block style enqueue (needed for FSE themes)
add_action(
'wp_footer',
function () {
echo '<style id="projects-wp-styles">';
echo file_get_contents( plugin_dir_path( __FILE__ ) . 'assets/css/style.css' ); // phpcs:ignore
echo '</style>';
}
);
} else {
// Classic theme enqueue
wp_enqueue_style( 'projects-wp-styles' );
}
}
if ( is_singular( 'projects' ) ) {
// assets/js/buttons.js was used to render GitHub-style follow buttons.
// The Follow button now uses a plain styled link (works for any provider),
// so the script is no longer enqueued.
}
}
add_action( 'wp_enqueue_scripts', 'projects_portfolio_enqueue_project_styles' );
/**
* Enqueue admin styles.
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_admin_styles() {
wp_enqueue_style( 'projects-wp-admin-css', plugin_dir_url( __FILE__ ) . 'assets/css/admin-styles.css' );
}
add_action( 'admin_enqueue_scripts', 'projects_portfolio_admin_styles' );
/**
* Modify the number of posts per page for the 'projects' post type archive.
*
* Ensures only the 'projects' archive page is affected without changing global settings.
*
* @param WP_Query $query The WordPress query object.
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_custom_posts_per_page( $query ) {
if ( is_admin() || ! $query->is_main_query() ) {
return;
}
$projects_per_page = apply_filters( 'projects_portfolio_archives_projects_per_page', 12 );
if ( is_post_type_archive( 'projects' ) ) {
$query->set( 'posts_per_page', $projects_per_page );
}
}
add_action( 'pre_get_posts', 'projects_portfolio_custom_posts_per_page', 1 );
/**
* Modify the posts per page for the 'projects' post type archive in Full Site Editing (FSE) themes.
*
* This function ensures that the 'projects' post type archive displays 12 posts per page,
* even when using an FSE theme with a Query Loop block.
*
* @param array $query The existing query variables for the Query Loop block.
* @param object $block The current block object (not used but available if needed).
*
* @since 1.0.0
* @return array Modified query variables with 'posts_per_page' set for the projects archive.
*/
function modify_projects_posts_per_page_fse( $query, $block ) { // phpcs:ignore
// Ensure we are modifying the archive query for the 'projects' post type.
if ( isset( $query['post_type'] ) && $query['post_type'] === 'projects' ) {
$query['posts_per_page'] = 12;
}
return $query;
}
add_filter( 'query_loop_block_query_vars', 'modify_projects_posts_per_page_fse', 10, 2 );
/**
* Add social sharing buttons with Tabler icons.
*
* @param int $project_id The project post ID.
*
* @since 1.0.0
* @return void
*/
function projects_portfolio_social_sharing_buttons( $project_id ) {
$url = rawurlencode( get_permalink( $project_id ) );
$title = rawurlencode( get_the_title( $project_id ) );
$html = '<div class="project-social-sharing">';
$svg_icon = function( string $name, string $path ) use ( &$html ) {
// Render each social icon as an <img> with an inline SVG data URI.
// Using <img> instead of inline <svg> ensures browser CSS sizing and
// avoids any namespace/parser quirks with inline SVG in mixed content.
$svg = '<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="#0073aa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">' . $path . '</svg>';
$data_uri = 'data:image/svg+xml;utf8,' . rawurlencode( $svg );
$html .= '<img src="' . esc_attr( $data_uri ) . '" alt="' . esc_attr( $name ) . '" width="20" height="20" style="width:20px;height:20px;display:inline-block;vertical-align:middle;" />';
};
// Facebook.
$html .= '<a href="https://www.facebook.com/sharer/sharer.php?u=' . $url . '" target="_blank" rel="noopener noreferrer">';
$svg_icon( 'Facebook', '<path d="M7 10v-3a1 1 0 0 1 1 -1h3v-4h4v4h3a1 1 0 0 1 1 1v3h-4v10h-4v-10h-3" />' );
$html .= '</a>';
// X (Twitter).
$html .= '<a href="https://twitter.com/intent/tweet?text=' . $title . '&url=' . $url . '" target="_blank" rel="noopener noreferrer">';
$svg_icon( 'X', '<path d="M7 4l10 16m0 -16l-10 16" />' );
$html .= '</a>';
// LinkedIn.
$html .= '<a href="https://www.linkedin.com/sharing/share-offsite/?url=' . $url . '" target="_blank" rel="noopener noreferrer">';
$svg_icon( 'LinkedIn', '<rect x="4" y="4" width="16" height="16" rx="2" /><line x1="8" y1="11" x2="8" y2="16" /><line x1="8" y1="8" x2="8" y2="8.01" /><line x1="12" y1="16" x2="12" y2="11" /><path d="M16 16v-3a2 2 0 0 0 -4 0" />' );
$html .= '</a>';
// Reddit.
$html .= '<a href="https://www.reddit.com/submit?url=' . $url . '&title=' . $title . '" target="_blank" rel="noopener noreferrer">';
$svg_icon( 'Reddit', '<circle cx="12" cy="12" r="9" /><path d="M12 12m-6 0a6 6 0 1 0 12 0a6 6 0 1 0 -12 0" /><path d="M12 12h0" /><path d="M8.5 13c.667 .667 1.333 1 2 1s1.333 -.333 2 -1" />' );
$html .= '</a>';
// WhatsApp.
$html .= '<a href="https://wa.me/?text=' . $title . '%20' . $url . '" target="_blank" rel="noopener noreferrer">';
$svg_icon( 'WhatsApp', '<path d="M3 21l1.65 -4.75a9 9 0 1 1 3.85 3.85z" /><path d="M9 10c1 2 3 3 5 4" /><path d="M9 13c1 1 2 2 4 3" />' );
$html .= '</a>';
// Pinterest.
$html .= '<a href="https://pinterest.com/pin/create/button/?url=' . $url . '&description=' . $title . '" target="_blank" rel="noopener noreferrer">';
$svg_icon( 'Pinterest', '<circle cx="12" cy="12" r="9" /><path d="M8 17c1 -2 1.5 -4 .5 -6s-.5 -4 .5 -6" /><path d="M15 11c.667 -1 1.667 -2.333 3 -4" />' );
$html .= '</a>';
// Email.
$html .= '<a href="mailto:?subject=' . $title . '&body=' . $url . '" target="_blank" rel="noopener noreferrer">';
$svg_icon( 'Email', '<path d="M3 7l9 6l9 -6" /><rect x="3" y="5" width="18" height="14" rx="2" />' );
$html .= '</a>';
$html .= '</div>';
// wp_kses_post() strips <svg> tags by default (the 'post' context doesn't
// allow them), and would also strip <img> data: URIs we use as a fallback.
// Extend the allowed list to keep both forms intact so the icons render.
$allowed = wp_kses_allowed_html( 'post' );
$allowed['img'] = array(
'src' => true,
'alt' => true,
'width' => true,
'height' => true,
'style' => true,
'aria-hidden' => true,
);
$allowed['svg'] = array(
'xmlns' => true,
'class' => true,
'width' => true,
'height' => true,
'viewBox' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'stroke-linecap' => true,
'stroke-linejoin' => true,
'style' => true,
'aria-hidden' => true,
'focusable' => true,
);
$allowed['path'] = array(
'd' => true,
'fill' => true,
'stroke' => true,
);
$allowed['rect'] = array(
'x' => true,
'y' => true,
'width' => true,
'height' => true,
'fill' => true,
'stroke' => true,
);
$allowed['circle'] = array(
'cx' => true,
'cy' => true,
'r' => true,
'fill' => true,
'stroke' => true,
);
$allowed['line'] = array(
'x1' => true,
'y1' => true,
'x2' => true,
'y2' => true,
'stroke' => true,
);
echo wp_kses( $html, $allowed );
}
add_action( 'projects_after_download_button', 'projects_portfolio_social_sharing_buttons', 999 );