Refresh player with native block editor preview and local assets

Preserve shortcode rendering, normalize options, bundle the player locally,
and add reproducible packaging and WordPress integration tests.
This commit is contained in:
2026-10-12 17:56:08 +08:00
parent 40e2bf0733
commit f04600c37b
90 changed files with 28835 additions and 2090 deletions
+114
View File
@@ -0,0 +1,114 @@
<?php
/**
* Locally bundled, shared player assets and block registration.
*
* @package LamGC\Asciiplayer
*/
namespace LamGC\Asciiplayer;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Register the block, shortcode and all plugin-scoped asset handles.
*
* @return void
*/
function register_plugin() {
$base = plugin_dir_url( PLUGIN_FILE ) . 'build/';
$asset = array(
'dependencies' => array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n' ),
'version' => VERSION,
);
if ( file_exists( dirname( PLUGIN_FILE ) . '/build/index.asset.php' ) ) {
$asset = require dirname( PLUGIN_FILE ) . '/build/index.asset.php';
}
$view_asset = array( 'dependencies' => array( 'wp-i18n' ), 'version' => VERSION );
if ( file_exists( dirname( PLUGIN_FILE ) . '/build/view.asset.php' ) ) {
$view_asset = require dirname( PLUGIN_FILE ) . '/build/view.asset.php';
}
wp_register_script( 'lamgc-asciiplayer-player', $base . 'player.js', array(), VERSION, true );
wp_register_script(
'lamgc-asciiplayer-view',
$base . 'view.js',
array_unique( array_merge( $view_asset['dependencies'], array( 'lamgc-asciiplayer-player' ) ) ),
$view_asset['version'],
true
);
wp_register_script(
'lamgc-asciiplayer-editor',
$base . 'index.js',
array_unique( array_merge( $asset['dependencies'], array( 'lamgc-asciiplayer-player' ) ) ),
$asset['version'],
true
);
wp_register_style( 'lamgc-asciiplayer-player-style', $base . 'player.css', array(), VERSION );
wp_register_style( 'lamgc-asciiplayer-style', $base . 'style-index.css', array( 'lamgc-asciiplayer-player-style' ), VERSION );
wp_register_style( 'lamgc-asciiplayer-editor-style', $base . 'index.css', array( 'lamgc-asciiplayer-player-style' ), VERSION );
wp_add_inline_script(
'lamgc-asciiplayer-editor',
'window.wpAsciiplayerSettings = ' . wp_json_encode(
array(
'playerScriptUrl' => add_query_arg( 'ver', VERSION, $base . 'player.js' ),
'playerStyleUrl' => add_query_arg( 'ver', VERSION, $base . 'player.css' ),
),
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
) . ';',
'before'
);
wp_set_script_translations( 'lamgc-asciiplayer-view', 'wp-asciiplayer', dirname( PLUGIN_FILE ) . '/languages' );
wp_set_script_translations( 'lamgc-asciiplayer-editor', 'wp-asciiplayer', dirname( PLUGIN_FILE ) . '/languages' );
register_block_type( dirname( PLUGIN_FILE ) . '/build', array( 'render_callback' => __NAMESPACE__ . '\\render_block' ) );
add_shortcode( SHORTCODE, __NAMESPACE__ . '\\render_shortcode' );
}
/**
* Enqueue in the shared renderer as well, for widgets and template calls.
* WordPress deduplicates handles across any number of players on a page.
*
* @return void
*/
function enqueue_player_assets() {
wp_enqueue_script( 'lamgc-asciiplayer-view' );
wp_enqueue_style( 'lamgc-asciiplayer-style' );
}
/**
* Look ahead before the head is printed, including Pages and archive results.
* Render-time enqueueing remains the source of truth for indirect content.
*
* @return void
*/
function enqueue_content_assets() {
global $wp_query;
if ( empty( $wp_query->posts ) || ! is_array( $wp_query->posts ) ) {
return;
}
foreach ( $wp_query->posts as $post ) {
if ( ! is_object( $post ) || ! isset( $post->post_content ) ) {
continue;
}
if ( has_shortcode( $post->post_content, SHORTCODE ) || has_block( 'lamgc/asciiplayer', $post->post_content ) ) {
enqueue_player_assets();
return;
}
}
}
/**
* Print only our late-enqueued CSS, before footer scripts initialize players.
* This covers dynamically rendered shortcodes in widgets and classic themes.
*
* @return void
*/
function print_late_styles() {
if ( wp_style_is( 'lamgc-asciiplayer-style', 'enqueued' ) && ! wp_style_is( 'lamgc-asciiplayer-style', 'done' ) ) {
wp_print_styles( array( 'lamgc-asciiplayer-style' ) );
}
}
+297
View File
@@ -0,0 +1,297 @@
<?php
/**
* Validation shared by the shortcode and dynamic block.
*
* @package LamGC\Asciiplayer
*/
namespace LamGC\Asciiplayer;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Normalize shortcode's lowercase names, camelCase block names and old aliases.
* Unknown options are intentionally discarded before reaching the player.
*
* @param mixed $attributes Input attributes.
* @return array
*/
function normalize_attributes( $attributes ) {
if ( ! is_array( $attributes ) ) {
return array();
}
$aliases = array(
'source' => 'source',
'src' => 'source',
'autoplay' => 'autoPlay',
'preload' => 'preload',
'loop' => 'loop',
'controls' => 'controls',
'fit' => 'fit',
'theme' => 'theme',
'speed' => 'speed',
'startat' => 'startAt',
'idletimelimit' => 'idleTimeLimit',
'cols' => 'cols',
'rows' => 'rows',
'fontsize' => 'terminalFontSize',
'terminalfontsize' => 'terminalFontSize',
'fontfamily' => 'terminalFontFamily',
'terminalfontfamily' => 'terminalFontFamily',
'lineheight' => 'terminalLineHeight',
'terminallineheight' => 'terminalLineHeight',
'poster' => 'poster',
'width' => 'width',
'style' => 'legacyStyle',
'legacystyle' => 'legacyStyle',
);
$result = array();
foreach ( $attributes as $key => $value ) {
if ( ! is_string( $key ) || ! is_scalar( $value ) ) {
continue;
}
$key = strtolower( str_replace( array( '-', '_' ), '', $key ) );
if ( isset( $aliases[ $key ] ) ) {
$result[ $aliases[ $key ] ] = $value;
}
}
return $result;
}
/**
* Accept HTTP(S) and site-root-relative recordings, never executable schemes.
* No requests are made by PHP, and URL credentials are never emitted.
*
* @param mixed $source Recording URL.
* @return string Sanitized URL, or an empty string for invalid input.
*/
function sanitize_source( $source ) {
if ( ! is_string( $source ) ) {
return '';
}
$source = trim( html_entity_decode( $source, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
if ( '' === $source || preg_match( '/[\x00-\x20\x7f<>"\\\\]/', $source ) ) {
return '';
}
$parts = wp_parse_url( $source );
if ( false === $parts || ! is_array( $parts ) || isset( $parts['user'] ) || isset( $parts['pass'] ) ) {
return '';
}
if ( '/' === substr( $source, 0, 1 ) ) {
// Reject protocol-relative URLs, including slash/backslash variants.
if ( '/' === substr( $source, 1, 1 ) || isset( $parts['host'] ) || isset( $parts['scheme'] ) ) {
return '';
}
} elseif ( empty( $parts['host'] ) || empty( $parts['scheme'] ) || ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true ) ) {
return '';
}
return esc_url_raw( $source, array( 'http', 'https' ) );
}
/**
* Parse explicit false values rather than casting a nonempty string to true.
*
* @param mixed $value Input.
* @return bool|null Null indicates an unrecognized value.
*/
function parse_boolean( $value ) {
if ( is_bool( $value ) ) {
return $value;
}
if ( ! is_scalar( $value ) ) {
return null;
}
$value = strtolower( trim( (string) $value ) );
if ( in_array( $value, array( '1', 'true', 'yes', 'on' ), true ) ) {
return true;
}
if ( in_array( $value, array( '0', 'false', 'no', 'off' ), true ) ) {
return false;
}
return null;
}
/**
* Validate a finite number, retaining legitimate zero values.
*
* @param mixed $value Input.
* @param float $min Minimum value.
* @param float $max Maximum value.
* @return float|null
*/
function parse_number( $value, $min, $max ) {
if ( is_bool( $value ) || ! is_scalar( $value ) || ! is_numeric( $value ) ) {
return null;
}
$value = (float) $value;
return is_finite( $value ) && $value >= $min && $value <= $max ? $value : null;
}
/**
* Convert the player's supported timecode syntax to seconds.
*
* @param mixed $value Seconds, mm:ss or hh:mm:ss.
* @return float|null
*/
function parse_time( $value ) {
$number = parse_number( $value, 0, PHP_INT_MAX );
if ( null !== $number ) {
return $number;
}
if ( ! is_string( $value ) || ! preg_match( '/^\d+(?::[0-5]?\d){1,2}(?:\.\d+)?$/', trim( $value ) ) ) {
return null;
}
$total = 0.0;
foreach ( explode( ':', trim( $value ) ) as $part ) {
$total = $total * 60 + (float) $part;
}
return is_finite( $total ) && $total <= PHP_INT_MAX ? $total : null;
}
/**
* Return a single safe CSS length for width/font size.
*
* @param mixed $value Input.
* @param bool $font Whether player font-size names are allowed.
* @return string
*/
function sanitize_size( $value, $font = false ) {
if ( ! is_string( $value ) && ! is_numeric( $value ) ) {
return '';
}
$value = strtolower( trim( (string) $value ) );
if ( $font && in_array( $value, array( 'small', 'medium', 'big' ), true ) ) {
return $value;
}
return preg_match( '/^(?:0|(?:\d+(?:\.\d+)?|\.\d+)(?:px|%|em|rem|vw|vh|vmin|vmax|ch))$/', $value ) ? $value : '';
}
/**
* Produce only typed, supported player options; never JavaScript source.
*
* @param array $attributes Normalized attributes.
* @return array
*/
function player_options( array $attributes ) {
$options = array(
'autoPlay' => false,
'preload' => true,
'fit' => 'width',
);
foreach ( array( 'autoPlay', 'preload', 'loop' ) as $name ) {
if ( isset( $attributes[ $name ] ) ) {
$value = parse_boolean( $attributes[ $name ] );
if ( null !== $value ) {
$options[ $name ] = $value;
} elseif ( 'loop' === $name ) {
// Older shortcodes can specify a finite number of replays.
$value = parse_number( $attributes[ $name ], 1, 10000 );
if ( null !== $value && floor( $value ) === $value ) {
$options[ $name ] = (int) $value;
}
}
}
}
if ( isset( $attributes['controls'] ) ) {
$value = strtolower( trim( (string) $attributes['controls'] ) );
if ( 'auto' === $value ) {
$options['controls'] = 'auto';
} elseif ( in_array( $value, array( 'always', 'never' ), true ) ) {
$options['controls'] = 'always' === $value;
} else {
$value = parse_boolean( $attributes['controls'] );
if ( null !== $value ) {
$options['controls'] = $value;
}
}
}
if ( isset( $attributes['fit'] ) ) {
$value = strtolower( trim( (string) $attributes['fit'] ) );
if ( in_array( $value, array( 'width', 'height', 'both', 'none' ), true ) ) {
$options['fit'] = $value;
} elseif ( false === parse_boolean( $attributes['fit'] ) ) {
$options['fit'] = 'none';
}
}
if ( isset( $attributes['theme'] ) && preg_match( '/^(?:auto\/)?[a-zA-Z0-9_-]{1,64}$/', (string) $attributes['theme'] ) ) {
$options['theme'] = (string) $attributes['theme'];
}
foreach ( array( 'speed' => array( 0.01, 100 ), 'idleTimeLimit' => array( 0.001, PHP_INT_MAX ), 'terminalLineHeight' => array( 0.1, 10 ) ) as $name => $range ) {
if ( isset( $attributes[ $name ] ) ) {
$value = parse_number( $attributes[ $name ], $range[0], $range[1] );
if ( null !== $value ) {
$options[ $name ] = $value;
}
}
}
if ( isset( $attributes['startAt'] ) ) {
$value = parse_time( $attributes['startAt'] );
if ( null !== $value ) {
$options['startAt'] = $value;
}
}
foreach ( array( 'cols', 'rows' ) as $name ) {
if ( isset( $attributes[ $name ] ) ) {
$value = parse_number( $attributes[ $name ], 1, 1000 );
if ( null !== $value && floor( $value ) === $value ) {
$options[ $name ] = (int) $value;
}
}
}
if ( isset( $attributes['terminalFontSize'] ) ) {
$value = sanitize_size( $attributes['terminalFontSize'], true );
if ( '' !== $value ) {
$options['terminalFontSize'] = $value;
}
}
if ( isset( $attributes['terminalFontFamily'] ) ) {
$value = trim( (string) $attributes['terminalFontFamily'] );
if ( strlen( $value ) <= 300 && preg_match( '/^[a-zA-Z0-9 _.,\'"-]+$/', $value ) ) {
$options['terminalFontFamily'] = $value;
}
}
if ( isset( $attributes['poster'] ) ) {
$value = (string) $attributes['poster'];
if ( 0 === strpos( $value, 'npt:' ) && null !== parse_time( substr( $value, 4 ) ) ) {
$options['poster'] = $value;
} elseif ( 0 === strpos( $value, 'data:text/plain,' ) && strlen( $value ) <= 32768 ) {
$options['poster'] = $value;
}
}
return $options;
}
/**
* Filter legacy inline style, then apply the dedicated width control if set.
*
* @param array $attributes Normalized attributes.
* @return string
*/
function player_style( array $attributes ) {
$style = isset( $attributes['legacyStyle'] ) ? safecss_filter_attr( (string) $attributes['legacyStyle'] ) : '';
if ( isset( $attributes['width'] ) ) {
$width = sanitize_size( $attributes['width'] );
if ( '' !== $width ) {
$style = rtrim( $style, '; ' ) . ';width:' . $width . ';';
}
}
return ltrim( $style, '; ' );
}
+66
View File
@@ -0,0 +1,66 @@
<?php
/**
* Safe, shared rendering for shortcode and block content.
*
* @package LamGC\Asciiplayer
*/
namespace LamGC\Asciiplayer;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Render one player. Invalid/missing sources produce no frontend markup.
*
* @param mixed $attributes Player attributes.
* @param string|null $content Optional legacy shortcode source.
* @param bool $block Whether WordPress block wrapper supports apply.
* @return string
*/
function render_player( $attributes = array(), $content = null, $block = false ) {
$attributes = normalize_attributes( $attributes );
$source = sanitize_source( null !== $content ? $content : ( $attributes['source'] ?? '' ) );
if ( '' === $source ) {
return '';
}
$options = wp_json_encode( player_options( $attributes ), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT );
if ( false === $options ) {
return '';
}
enqueue_player_assets();
$style = player_style( $attributes );
$wrapper = $block
? get_block_wrapper_attributes( array( 'class' => 'asciiplayer-container', 'style' => $style ) )
: 'class="asciiplayer-container"' . ( '' === $style ? '' : ' style="' . esc_attr( $style ) . '"' );
return sprintf(
'<div %1$s data-ap-src="%2$s" data-ap-options="%3$s"></div>',
$wrapper,
esc_url( $source, array( 'http', 'https' ) ),
esc_attr( $options )
);
}
/**
* Preserve [asciiplayer]URL[/asciiplayer] and its existing options.
*
* @param mixed $attributes Shortcode attributes.
* @param string|null $content Enclosed URL.
* @return string
*/
function render_shortcode( $attributes = array(), $content = null ) {
return null === $content ? '' : render_player( $attributes, $content );
}
/**
* Dynamic block callback. Both authoring paths use exactly the same sanitizer.
*
* @param array $attributes Block attributes.
* @return string
*/
function render_block( $attributes ) {
return render_player( $attributes, null, true );
}