$atts
*/
public function render( $atts ): string {
$atts = shortcode_atts( [
'show' => 'courses,projects,people',
'animate' => 'yes',
'columns' => 3,
'duration' => 2000,
'courses_label' => '',
'projects_label' => '',
'people_label' => '',
'courses' => 'all',
'projects' => 'all',
'members_exclude' => '',
'num_size' => '',
'label_size' => '',
'num_color' => '',
'label_color' => '',
'round' => 0,
'suffix' => '+',
'courses_round' => '',
'courses_suffix' => '',
'projects_round' => '',
'projects_suffix' => '',
'people_round' => '',
'people_suffix' => '',
'custom' => '',
], (array) $atts, 'acc_stat_counters' );
$courses_scope = in_array( strtolower( (string) $atts['courses'] ), [ 'all', 'current' ], true )
? strtolower( (string) $atts['courses'] ) : 'all';
$projects_scope = in_array( strtolower( (string) $atts['projects'] ), [ 'all', 'active', 'past' ], true )
? strtolower( (string) $atts['projects'] ) : 'all';
$exclude_roles = $this->parse_excluded_roles( (string) $atts['members_exclude'] );
$show_raw = trim( strtolower( (string) $atts['show'] ) );
if ( 'none' === $show_raw ) {
$wanted = [];
} else {
$wanted = array_values( array_unique( array_filter(
array_map( 'trim', explode( ',', $show_raw ) ),
static fn( $m ) => in_array( $m, self::METRICS, true )
) ) );
if ( ! $wanted ) {
$wanted = self::METRICS;
}
}
$animate = 'no' !== strtolower( (string) $atts['animate'] );
$columns = max( 1, min( 4, (int) $atts['columns'] ) );
$duration = max( 200, min( 10000, (int) $atts['duration'] ) );
$num_size = $this->sanitize_css_size( (string) $atts['num_size'] );
$label_size = $this->sanitize_css_size( (string) $atts['label_size'] );
$num_color = $this->sanitize_css_color( (string) $atts['num_color'] );
$label_color = $this->sanitize_css_color( (string) $atts['label_color'] );
$def_round = max( 0, min( 100000, (int) $atts['round'] ) );
$def_suffix = mb_substr( (string) $atts['suffix'], 0, 8 );
$labels = [
'courses' => $atts['courses_label'] ?: __( 'Courses', 'academiccrew' ),
'projects' => $atts['projects_label'] ?: __( 'Projects', 'academiccrew' ),
'people' => $atts['people_label'] ?: __( 'People', 'academiccrew' ),
];
$counts = [
'courses' => $this->count_courses( $courses_scope ),
'projects' => $this->count_projects( $projects_scope ),
'people' => $this->count_members( $exclude_roles ),
];
// Build one flat list of counters — built-ins the caller asked for,
// then the hand-entered custom ones — each with its own round/suffix
// (falling back to the round="" / suffix="" defaults above).
$counters = [];
foreach ( $wanted as $metric ) {
[ $round, $suffix ] = $this->resolve_round(
(string) $atts[ $metric . '_round' ],
(string) $atts[ $metric . '_suffix' ],
$def_round,
$def_suffix
);
$counters[] = [
'key' => $metric,
'label' => $labels[ $metric ],
'raw' => (int) $counts[ $metric ],
'round' => $round,
'suffix' => $suffix,
];
}
array_push( $counters, ...$this->parse_custom( (string) $atts['custom'], $def_round, $def_suffix ) );
if ( ! $counters ) {
return '';
}
$wrap_style = '--acc-stats-cols:' . $columns;
if ( '' !== $num_size ) {
$wrap_style .= ';--acc-stat-num-size:' . $num_size;
}
if ( '' !== $label_size ) {
$wrap_style .= ';--acc-stat-label-size:' . $label_size;
}
if ( '' !== $num_color ) {
$wrap_style .= ';--acc-stat-num-color:' . $num_color;
}
if ( '' !== $label_color ) {
$wrap_style .= ';--acc-stat-label-color:' . $label_color;
}
ob_start();
?>
0 && $c['raw'] >= $c['round'] ) {
$value = intdiv( $c['raw'], $c['round'] ) * $c['round'];
$with_suffix = $c['suffix'];
} else {
$value = $c['raw'];
$with_suffix = '';
}
?>
>
enqueue_assets_once();
return (string) ob_get_clean();
}
/**
* A per-counter round/suffix override, falling back to the widget-wide
* default when left blank ("").
*
* @return array{0:int,1:string}
*/
private function resolve_round( string $round_override, string $suffix_override, int $default_round, string $default_suffix ): array {
$round = '' !== trim( $round_override ) ? max( 0, min( 100000, (int) $round_override ) ) : $default_round;
$suffix = '' !== $suffix_override ? mb_substr( $suffix_override, 0, 8 ) : $default_suffix;
return [ $round, $suffix ];
}
/**
* Parses the "custom" textarea: one counter per line,
* "Label | Value | Round | Suffix" (Round/Suffix optional).
*
* @return array
*/
private function parse_custom( string $raw, int $default_round, string $default_suffix ): array {
$out = [];
$n = 0;
foreach ( preg_split( '/\r\n|\r|\n/', $raw ) as $line ) {
$line = trim( $line );
if ( '' === $line || $n >= self::MAX_CUSTOM ) {
continue;
}
$parts = array_map( 'trim', explode( '|', $line ) );
$label = sanitize_text_field( $parts[0] ?? '' );
if ( '' === $label ) {
continue;
}
$n++;
[ $round, $suffix ] = $this->resolve_round(
$parts[2] ?? '',
$parts[3] ?? '',
$default_round,
$default_suffix
);
$out[] = [
'key' => 'custom-' . $n,
'label' => $label,
'raw' => max( 0, (int) preg_replace( '/[^0-9-]/', '', $parts[1] ?? '0' ) ),
'round' => $round,
'suffix' => $suffix,
];
}
return $out;
}
/**
* Allow a CSS length / clamp() expression only — digits, units, and the
* handful of characters `clamp()`/`calc()` need. Anything else → ''.
*/
private function sanitize_css_size( string $raw ): string {
$raw = trim( $raw );
if ( '' === $raw || strlen( $raw ) > 60 ) {
return '';
}
return preg_match( '/^[0-9a-z.,%()\s\/*+-]+$/i', $raw ) ? $raw : '';
}
/**
* Allow a CSS color value only — hex, named colors, rgb()/rgba()/hsl(),
* or a var() reference. Anything else (no ':' means no javascript: URLs
* can sneak in) → ''.
*/
private function sanitize_css_color( string $raw ): string {
$raw = trim( $raw );
if ( '' === $raw || strlen( $raw ) > 60 ) {
return '';
}
return preg_match( '/^[#0-9a-z.,%()\s-]+$/i', $raw ) ? $raw : '';
}
/** @return int[] published post IDs of a type (unfiltered). */
private function ids( string $post_type ): array {
return get_posts( [
'post_type' => $post_type,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'no_found_rows' => true,
'suppress_filters' => true,
] );
}
/** courses="all" | "current" (only the latest academic year). */
private function count_courses( string $scope ): int {
if ( 'current' !== $scope ) {
return (int) ( wp_count_posts( 'academiccrew_course' )->publish ?? 0 );
}
$current_ay = ( new AcademicYearCalculator() )->calculate( 1 );
return count( get_posts( [
'post_type' => 'academiccrew_course',
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'no_found_rows' => true,
'meta_query' => [ [
'key' => '_acc_academic_year',
'value' => $current_ay,
'compare' => '>=',
'type' => 'NUMERIC',
] ],
] ) );
}
/** projects="all" | "active" (ongoing) | "past" (completed). */
private function count_projects( string $scope ): int {
if ( 'all' === $scope ) {
return (int) ( wp_count_posts( 'acc_collaboration' )->publish ?? 0 );
}
$today = current_time( 'Y-m-d' );
$n = 0;
foreach ( $this->ids( 'acc_collaboration' ) as $id ) {
$end = (string) get_post_meta( (int) $id, '_acc_end_date', true );
$completed = '' !== $end && $end < $today;
if ( ( 'past' === $scope ) === $completed ) {
$n++;
}
}
return $n;
}
/** People count, minus any excluded role slugs. */
private function count_members( array $exclude_roles ): int {
if ( ! $exclude_roles ) {
return (int) ( wp_count_posts( 'academiccrew_member' )->publish ?? 0 );
}
$n = 0;
foreach ( $this->ids( 'academiccrew_member' ) as $id ) {
$role = (string) get_post_meta( (int) $id, '_acc_member_role', true ) ?: 'collaborator';
if ( ! in_array( $role, $exclude_roles, true ) ) {
$n++;
}
}
return $n;
}
/**
* "phd-student, former" → ['phd-student', 'former-professor', …, 'alumni'].
* Unknown slugs are dropped.
*
* @return string[]
*/
private function parse_excluded_roles( string $raw ): array {
$tokens = array_filter( array_map( 'trim', explode( ',', strtolower( $raw ) ) ) );
if ( ! $tokens ) {
return [];
}
$valid = RoleRegistry::keys();
$out = [];
foreach ( $tokens as $token ) {
if ( 'former' === $token ) {
foreach ( $valid as $key ) {
if ( str_starts_with( $key, 'former-' ) || 'alumni' === $key ) {
$out[] = $key;
}
}
} elseif ( in_array( $token, $valid, true ) ) {
$out[] = $token;
}
}
return array_values( array_unique( $out ) );
}
/**
* Enqueues the inline CSS + count-up JS, at most once per request.
*/
private function enqueue_assets_once(): void {
static $done = false;
if ( $done ) {
return;
}
$done = true;
$css = '.acc-stats{display:grid;grid-template-columns:repeat(var(--acc-stats-cols,3),1fr);gap:24px;margin:1.5rem 0;text-align:center}'
. '.acc-stat{display:flex;flex-direction:column;gap:6px;padding:12px 8px}'
. '.acc-stat-num{font-family:var(--acc-font-display,"Roboto Slab",Georgia,serif);font-weight:800;font-size:var(--acc-stat-num-size,clamp(2.4rem,5vw,3.6rem));line-height:1;color:var(--acc-stat-num-color,var(--acc-primary,#0d2340));font-variant-numeric:tabular-nums}'
. '.acc-stat-label{font-family:var(--acc-font-mono,monospace);font-size:var(--acc-stat-label-size,11px);letter-spacing:.16em;text-transform:uppercase;color:var(--acc-stat-label-color,var(--acc-muted,#8893a6))}'
. '@media(max-width:600px){.acc-stats{grid-template-columns:1fr;gap:14px}}'
. '@media(prefers-reduced-motion:reduce){.acc-stat-num{transition:none}}';
$js = "(function(){"
. "if(window.__accStatsInit)return;window.__accStatsInit=1;"
. "var reduce=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;"
. "function fmt(n){try{return n.toLocaleString();}catch(e){return String(n);}}"
. "function animate(el){"
. "var target=parseInt(el.getAttribute('data-acc-count'),10)||0;"
. "var sfx=el.getAttribute('data-acc-suffix')||'';"
. "if(reduce||!el.hasAttribute('data-acc-animate')){el.textContent=fmt(target)+sfx;return;}"
. "var dur=parseInt(el.getAttribute('data-acc-duration'),10)||2000,start=null;"
. "function step(ts){"
. "if(start===null)start=ts;"
. "var p=Math.min((ts-start)/dur,1),eased=1-Math.pow(1-p,3);"
. "el.textContent=fmt(Math.round(eased*target))+sfx;"
. "if(p<1)requestAnimationFrame(step);else el.textContent=fmt(target)+sfx;"
. "}"
. "requestAnimationFrame(step);"
. "}"
. "function run(){"
. "var nums=document.querySelectorAll('.acc-stat-num[data-acc-count]');"
. "if(!nums.length)return;"
. "if(!('IntersectionObserver' in window)){Array.prototype.forEach.call(nums,animate);return;}"
. "var io=new IntersectionObserver(function(entries){"
. "entries.forEach(function(e){if(e.isIntersecting){animate(e.target);io.unobserve(e.target);}});"
. "},{threshold:0.4});"
. "Array.prototype.forEach.call(nums,function(n){io.observe(n);});"
. "}"
. "if(document.readyState!=='loading')run();"
. "else document.addEventListener('DOMContentLoaded',run);"
. "}());";
wp_register_style( 'acc-stat-counters', false, [], ACADEMICCREW_VERSION );
wp_add_inline_style( 'acc-stat-counters', $css );
wp_enqueue_style( 'acc-stat-counters' );
wp_register_script( 'acc-stat-counters', '', [], ACADEMICCREW_VERSION, true );
wp_add_inline_script( 'acc-stat-counters', $js );
wp_enqueue_script( 'acc-stat-counters' );
}
}