This is my technology test page. I have a local instance of changedetector.io pointed at this page to alert me if any of these website features change unexpectedly, meaning that there is a plugin or theme conflict or some other problem causing unintended consequences across the site.
You really have no reason to be looking at this.
Photonic gallery
Cached photonic gallery
Dingbat

Details previews
Summary:
blah blah blah blah blah
Emgithub.js
File dump
<?php
/**
* The template for displaying Hero Hover Slider.
*
* @package Sinatra
* @author Sinatra Team <hello@sinatrawp.com>
* @since 1.0.0
*/
$postsperrow = 3;
$sinatra_hero_categories = !empty($sinatra_hero_categories)
? implode(", ", $sinatra_hero_categories)
: "";
// Setup Hero posts.
//function herorow($postsperpage,$offset,$order,$sinatra_hero_categories) {return '<h2>hi '.$offset.'</h2>';}
function count_total_post_media( $id ) {
$data = getFunctionTransient(__FUNCTION__, $id); if ( $data != null) {return intval($data);}
// from https://wordpress.stackexchange.com/questions/246055/count-total-number-of-images-in-post-and-echo-results-as-number
$array = get_post_galleries( $id, false );
$key = 0;
$theOut=array();
// $src = 0;
while ( $key < count( $array ) ){
// $src += count( $array[$key]['src'] );
if (array_key_exists('ids',$array[$key])) {$theOut= array_merge($theOut,explode(',',$array[$key]['ids']));}
/* added conditional to above line on 2025apr25 because log logged a warning: Undefined array key "ids" */
$key++;
}
$newarray=array_column(get_children($id),'ID'); //get_children returns an array of obects, which has to be handled differently than ordinary arrays, because otherwise it wouldn't be PHP
/*DEBUGGING if(is_user_logged_in()) {echo "<!-- ".print_r($newarray)." -->";echo "<!-- ".get_post_thumbnail_id($id, "full")." -->"; } */
$keynew = 0;
// $srcnew = 0;
$theOutnew=array();
while ( $keynew < count( $newarray ) ){
// $src += count( $array[$keynew]['src'] );
$theOutnew[]= $newarray[$keynew];
$keynew++;
}
$theFinalArray=array_unique(array_merge($theOut,$theOutnew));
if(has_post_thumbnail( $id )) {
if (($key = array_search(get_post_thumbnail_id($id) , $theFinalArray)) !== false) { unset($theFinalArray[$key]); } /* else { echo "<!-- ".array_search(get_the_post_thumbnail_url($id, "full") , $theFinalArray)." -->";} */
}
/* NOTE: Tested, this already doesn't detect LJ-divider because it's added by a shortcode */
$theFinalCount=count($theFinalArray); // was "$theFinalCount=count($theFinalArray)-$hasThumb;" at the end, but this appears to be leftover from something removed, $hasthumb isn't defined anywhere
// if(is_user_logged_in()) {echo "<!-- ".print_r($theOut)." -->"; }
// return intval( $src );
//
setFunctionTransient(__FUNCTION__, $theFinalCount ,$id);
return intval( $theFinalCount );
}
/*
function OLDadd_dynamic_preload_script($imgUrl, $imgMidUrl) {
echo'
<script>
if (window.innerWidth > 600) {
const preload = new Image();
preload.src = "'. $imgUrl.'";
} else {
const preload = new Image();
preload.src = "'. $imgMidUrl.'";
}
</script>
';
}
*/
function add_dynamic_preload_script($imgUrl, $imgMidUrl) {
$theInsert = '<link rel="preload" href="'.esc_url($imgMidUrl).'" as="image" media="(max-width: 600px)" fetchpriority="high">
<link rel="preload" href="'.esc_url($imgUrl).'" as="image" media="(min-width: 601px)" fetchpriority="high">';
setFunctionTransient("headerInsert",$theInsert,"frontpage");
}
function herorow($postsperpage, $offset, $order, $sinatra_hero_categories,$theHeroTag)
{
$sinatra_args = [
"post_type" => "post",
"post_status" => "publish",
"order" => $order, //'ASC',
'orderby' => 'date',//'post_modified',
"posts_per_page" => $postsperpage, //3 //sinatra_option( 'hero_hover_slider_post_number' ), // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page
"offset" => $offset,
"ignore_sticky_posts" => true,
'tag' => ($theHeroTag=='all'?'':$theHeroTag),
"tax_query" => [
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
[
"taxonomy" => "post_format",
"field" => "slug",
"terms" => ["post-format-quote"],
"operator" => "NOT IN",
],
],
];
//echo "<!-- sopt ".var_dump(sinatra_option("hero_hover_slider_category"))." -->";
$sinatra_hero_categories = $theHeroTag!='all'?sinatra_option("hero_hover_slider_category"):array('articles');
$atts=array($postsperpage, $offset, $order, $sinatra_hero_categories,$theHeroTag );
$data = getFunctionTransient(__FUNCTION__, $atts); if ( $data != null) {
/* this can be moved way earlier in the process... put preload links in the header if these transients exist
if($offset==0){
$imgUrl = getFunctionTransient("HomeImgUrl", "home");
$imgMidUrl = getFunctionTransient("HomeMidImgUrl", "home");
add_dynamic_preload_script($imgUrl, $imgMidUrl) ;
}*/
return /*"<!-- transient ".__FUNCTION__." code for atts ". json_encode( $atts ) ." -->".*/ $data;}
if (!empty($sinatra_hero_categories)) {
$sinatra_args["category_name"] = implode(
", ",
$sinatra_hero_categories
);
}
$sinatra_args = apply_filters(
"sinatra_hero_hover_slider_query_args",
$sinatra_args
);
$sinatra_posts = new WP_Query($sinatra_args);
// No posts found.
if (!$sinatra_posts->have_posts()) {
return false;
}
$sinatra_hero_bgs_html = "";
$sinatra_hero_items_html = "";
$sinatra_hero_elements = (array) sinatra_option(
"hero_hover_slider_elements"
);
$sinatra_hero_readmore =
isset($sinatra_hero_elements["read_more"]) &&
$sinatra_hero_elements["read_more"]
? " si-hero-readmore"
: "";
$thisRowPostCount=1;
while ($sinatra_posts->have_posts()):
$sinatra_posts->the_post();
$this_image_id = get_post_thumbnail_id();
$this_image_alt = get_post_meta($this_image_id, '_wp_attachment_image_alt', TRUE);
$this_image_title = get_the_title($this_image_id);
if (get_the_title()) {$thistitle = get_the_title(); }
$pre_alt_tag = $this_image_alt?($this_image_alt.", feature article by Mike Kupietz"):($thistitle?($thistitle.", feature art by Mike Kupietz"):"feature art by Mike Kupietz");
$this_alt_tag= (strpos($pre_alt_tag, ': ') !== false) ? substr($pre_alt_tag, strpos($pre_alt_tag, ': ') + 1) : $pre_alt_tag;
$thumb = wp_get_attachment_image_src( get_post_thumbnail_id(get_the_ID()), 'full' );
$url = $thumb['0'];
$width = $thumb['1'];
$height = $thumb['2'];
$midthumb = wp_get_attachment_image_src( get_post_thumbnail_id(get_the_ID()), 'medium' );
$midurl = $midthumb['0'];
$midwidth = $midthumb['1'];
$midheight = $midthumb['2'];
if ($offset==0 && $thisRowPostCount==$postsperpage) {
setFunctionTransient("HomeImgUrl",$url, "home");
setFunctionTransient("HomeMidImgUrl",$midurl, "home");
add_dynamic_preload_script($url, $midurl) ;
}
$thisRowPostCount += 1;
// Background images HTML markup.
$sinatra_hero_bgs_html =
// 1px included below to prompt the lazy loader to fetch the images. Silly, I know. For a while I used full size images with just 0.001 opacity but sometimes it ignored the opacity.
'<div class="hover-slide-bg lazyload">' . '<img loading="'.($offset==0?"eager":"lazy").'" alt="'.htmlspecialchars($this_alt_tag).'" class="mk-hero-row-bg" src="'.
/*get_the_post_thumbnail_url(get_the_ID(), "full")*/$url .'" width="'.$width.'" height="'.$height.'" />' . '</div>' .
$sinatra_hero_bgs_html; //reversing the order. Must be reversed below too.
// Post items HTML markup.
// ob_start();
$postImgUrl=$midurl;//get_the_post_thumbnail_url(get_the_ID(), "medium");
ob_start();
echo '<div class="col-xs-' .
intdiv(12, $postsperpage) .
" xmkwrapper hover-slider-item-wrapper".iwfy(" h-entry");
echo esc_attr($sinatra_hero_readmore);
echo '" itemscope itemtype="https://schema.org/Article"><link itemprop="image" href="'.$postImgUrl.'"><img loading="'.($offset==0?"eager":"lazy").'" class="mk-hero-row-bg-mobile" alt="'.$this_alt_tag.'" src="'.
$postImgUrl .'" width="'.$midwidth.'" height="'.$midheight.'" />
<section style="display: none;" class="'.iwfy("p-author h-card ") .'vcard">
<span class="'. iwfy("p-name ").'fn">
Michael Kupietz | ARTS+CODE - Featured articles
</span>
</section>'.
/* u-card was originally:
<section style="display: none;" class="p-author h-card vcard">
<span class="p-name fn">
<span class="p-given-name given-name">Michael</span> <span class="p-family-name family-name">Kupietz</span> | ARTS+CODE - Featured articles
</span>
<a href="https://michaelkupietz.com/" class="u-url url">michaelkupietz.com</a>
<ul class="p-org h-card org">
<li>
<a href="https://michaelkupietz.com/" class="p-name u-url p-organization-name organization-name">Michael Kupietz | ARTS+CODE</a>
</li>
<!-- li>
<img src="/image/logo/kupietzlogo-256x256.png" alt="Logotype for michaelkupietz.com" class="u-logo logo">
</li -->
</ul>
</section>
*/
'<div class="hover-slide-item xmkparent-div">
<div class="slide-inner xmkpost-div">';
if (
isset($sinatra_hero_elements["category"]) &&
$sinatra_hero_elements["category"]
) {
//echo '<div class="post-category">';
sinatra_entry_meta_category(" ", true);
echo " ";
sinatra_entry_meta_genre(" ", false,false,0);
// echo "</div>";
}
if (get_the_title()) {
echo '<h2 class="hero_h3">';
$thepre = get_post_meta(get_the_ID(), "hero_pre", true);
if ($thepre) {
echo '<p class="hero_pre_p">' .
$thepre .
"</p>";
}
$theRel = get_post_meta(get_the_ID(), 'rel', true );
$theRel .= ($theRel?' ':'').'bookmark'; /* need bookmark for h-feed */
echo '<a class="hero_h3_a'.iwfy(" u-url").'" title="'.addslashes($thistitle).'" itemprop="headline" '. (($theRel)?(' rel="'.$theRel.'" '):'') . 'href="';
echo esc_url(sinatra_entry_get_permalink());
echo '">';
/* was
the_title();
...which includes echo() in executing. */
//moving this up to use in alt tags $thistitle = get_the_title();
$thiscategory = get_the_category(get_the_ID());
$thiscategoryterm = ($thiscategory[0]->name)=='feature'?($thiscategory[1]->term_id):($thiscategory[0]->term_id);
/* BugFu::log("title ",false);
BugFu::log($thistitle,false);
BugFu::log("thiscat ",false);
BugFu::log($thiscategory,false);
BugFu::log("thiscategoryterm ",false);
BugFu::log($thiscategoryterm,false); */
$titlepre = get_post_meta(get_the_ID(), "titleprefix", true);
$colonpos = strpos($thistitle, ":");
//I don't think this is ever even used anymore. Check.
if ($titlepre && !$thepre) { /* added && !$thepre in 2024aug6 to prevent both wihite italic pre and H6-style titlepre */
$thistitle='<span class="titleprefix">' .$titlepre.'</span>'.'<span class="'.iwfy("p-name").'">'.$thistitle.'</span>';
}
elseif ($colonpos !== false && !$thepre) { /* added && !$thepre in 2024aug6 to prevent both wihite italic pre and H6-style titlepre */
$thistitle = '<span class="titleprefix'.($thepre?' displaynone':'').'">' .
substr_replace($thistitle, ":</span>", $colonpos, strlen(":"));
/* str_replace(":", ":</span>"."<div class=\"" . getClassOfCatOrParentCat(get_the_category(get_the_ID())[0]->term_id)."\"></div>", $thistitle);
*/
} else {$thistitle= /* Don't know what this was but as of 2024dec1 it's not defined anywhere $theParentCatClass. */ $thistitle;}
// echo "<!-- id ".get_the_ID()." cat ".print_r(get_the_category(get_the_ID()))." -->";
echo ($thistitle);
$thepost = get_post_meta(get_the_ID(), "hero_post", true);
if ($thepost) {
echo '<p class="hero_pre_p"><i class="hero_h3_i">' .
$thepost .
"</i></p>";
}
/* end replacement */
echo "</a>";
/* WAS HERE $thepost = get_post_meta(get_the_ID(), "hero_post", true);
if ($thepost) {
echo '<p class="heropre" style="font-size:.6em;padding:.25em 0;margin:0;"><i class="hero_h3_i">' .
$thepost .
"</i></p>";
} */
echo "</h2>";
}
if (
isset($sinatra_hero_elements["meta"]) &&
$sinatra_hero_elements["meta"]
) {
echo ' <div class="slider-entry-meta entry-meta">
<div class="slider-entry-meta-elements entry-meta-elements">';
echo '<div class="herosummary'.iwfy(" p-summary").'" itemprop="description">'.get_the_excerpt().'</div>';
//sinatra_entry_meta_author();
/* sinatra_entry_meta_date([
"show_modified" => false,
"published_label" => "",
]); */
if (function_exists("bac_post_word_count")) {
// '<span class="wordcount">';
bac_post_word_count();
echo " words";
//echo "</span>";
}
$theMediaCount =/* count(get_attached_media(get_the_ID())) + count(get_children(get_the_ID())) + */ count_total_post_media(get_the_ID()); // - count(get_post_galleries(get_the_ID(),false)) ; //length(get_attached_media()) +
if ($theMediaCount) {
//if(is_user_logged_in()) {echo "<!-- ".var_dump(get_children(get_the_ID())).". -->"; }
echo ' | '; //'<span class="mediacount">';
echo $theMediaCount;
echo " media item";
echo $theMediaCount == 1 ? "" : "s";
// echo "</span>";
}
// echo '<span class="updateddate">' ;
echo ' | ';
echo '<time datetime='.get_the_modified_date('Y-m-d',get_the_ID()).' class="'.iwfy("dt-published").'">Updated '.get_the_modified_date('M j, Y',get_the_ID())/*publish date get_the_date('',get_the_ID())*/ /*get_post_modified_date_except(get_the_ID(), '2024-07-31')*/ .'</time>' /* must be <time>, not <span> for indieweb h-feeds */;
/*echo '</span>'; */
#semrush says I'm using my name too much echo ' <span itemprop="author" itemscope itemtype="http://schema.org/Person"><span class="hero-author-name author-name" itemprop="name"><a class="url fn n" title="View all posts by Michael Kupietz" href="https://michaelkupietz.com/author/mike-kupietz/" rel="author" itemprop="url">Mike Kupietz</a></span></span>';
echo '</div>';
echo'</div>';//<!-- END .entry-meta -->';
}
if ($sinatra_hero_readmore) {
echo '<a href="';
echo esc_url(sinatra_entry_get_permalink());
echo '" class="slider-read-more read-more si-btn btn-small btn-outline btn-uppercase" role="button"><span>';
echo esc_html_e("Click to Read More...", "sinatra");
echo "</span></a>";
}
echo '</div>' . comment('<!-- END .slide-inner -->').
'</div>' . comment('<!-- END .hover-slide-item -->').
'</div>' . comment('<!-- END .hover-slider-item-wrapper -->');
$sinatra_hero_items_html = ob_get_clean() . $sinatra_hero_items_html; //reverse the order because I feel like having it that way. Remember to fix the order of the background images too, above, if yu change it back.
endwhile;
// Restore original Post Data.
wp_reset_postdata();
// Hero container.
$sinatra_hero_container = sinatra_option("hero_hover_slider_container");
$sinatra_hero_container =
"full-width" === $sinatra_hero_container
? "si-container si-container__wide"
: "si-container";
// Hero overlay.
$sinatra_hero_overlay = absint(sinatra_option("hero_hover_slider_overlay"));
$htmloutfinal =
'<div class="si-hover-slider slider-overlay-' .
esc_attr($sinatra_hero_overlay) .
'" style="z-index:-10' .
/* $offset . */
'">
<div class="hover-slider-backgrounds">' .
wp_kses_post($sinatra_hero_bgs_html) .
'</div>' . comment('<!-- END .hover-slider-items -->').
'<div class="si-hero-container ' .
esc_attr($sinatra_hero_container) .
'">
<div class="si-flex-row hover-slider-items">' .
//wp_kses_post
($sinatra_hero_items_html) .
'</div>' . comment('<!-- END .hover-slider-items -->').
'</div>
<div class="si-spinner visible">
<div></div>
<div></div>
</div>
</div>' . comment('<!-- END .si-hover-slider -->');
setFunctionTransient(__FUNCTION__, $htmloutfinal ,$atts) ;
return /*"<!-- orignal code for atts ". json_encode( $atts ) ." -->".*/$htmloutfinal;
}
function get_post_modified_date_except($post_id, $cutoff_date = '2024-07-31') {
/* this was intended to cover for a bunch of records that had the mod date reseet to 7/31/24 by showing the last revision before that instead. But I went ahead and reverted them in the database instead so not using this. */
$post = get_post($post_id);
$modified_date = get_the_modified_date('Y-m-d', $post_id);
if ($modified_date === $cutoff_date) {
$revisions = wp_get_post_revisions($post_id, array(
'before' => $cutoff_date,
'limit' => 1,
'orderby' => 'date',
'order' => 'DESC'
));
if (!empty($revisions)) {
$last_revision = reset($revisions);
$modified_timestamp = strtotime($last_revision->post_modified);
// Check if the last revision's modified date is also the cutoff date
if (date('Y-m-d', $modified_timestamp) === $cutoff_date) {
// If the last revision's modified date is the cutoff date, get the previous revision
$previous_revision = next($revisions);
if ($previous_revision) {
$modified_timestamp = strtotime($previous_revision->post_modified);
}
}
$modified_date = date('Y-m-d', $modified_timestamp);
}
}
return date('M j, Y', strtotime($modified_date));
}
function add_query_vars_filter( $vars ) {
$vars[] = "herotag";
return $vars;
}
add_filter( 'query_vars', 'add_query_vars_filter' );
function makeHero($postsperrow,$sinatra_hero_categories) {
$theHeroTag = $_GET['featured'] ?? "";
$atts=array($postsperrow,$sinatra_hero_categories,$theHeroTag );
$data = getFunctionTransient(__FUNCTION__, $atts); if ( $data != null) {
echo /*"<!-- transient ".__FUNCTION__." code for atts ". json_encode( $atts ) ." -->".*/ $data; return;}
$thisOutput="";
$thisOutput .= '<div class="featuredheader">Filter articles:
<a '.(($theHeroTag=="")?('class="heroactive"'):'').' href="/">Featured</a>
<a '.(($theHeroTag=="site-info")?('class="heroactive"'):'').' href="/?featured=site-info">Site Info</a>
<a '.(($theHeroTag=="image-gallery")?('class="heroactive"'):'').' href="/?featured=image-gallery">Image Galleries</a>
<a '.(($theHeroTag=="music-sounds")?('class="heroactive"'):'').' href="/?featured=music-sounds">Music & Sounds</a>
<a '.(($theHeroTag=="photojournals")?('class="heroactive"'):'').' href="/?featured=photojournals">Photojournals</a>
<a '.(($theHeroTag=="writing")?('class="heroactive"'):'').' href="/?featured=writing">Writing</a>
<a '.(($theHeroTag=="code-algorithms")?('class="heroactive"'):'').' href="/?featured=code-algorithms">Code & Algorithms</a>
<a '.(($theHeroTag=="all")?('class="heroactive"'):'').' href="/?featured=all">All</a>
</div>';
$featured_posts_for_count = get_posts(array(
'cat' => ($theHeroTag=="all")?103:51,
'numberposts' => -1,
));
$featured_post_count_for_hero = count($featured_posts_for_count) - 1;
//$featured_post_count = count($featured_posts)-1; //3 posts should still return 0 since we want to add arow if there's 4 posts, not 4
$hero_height = 1+floor($featured_post_count_for_hero / 3);
for ($i = 0; $i <= $hero_height; $i++) {
$thisOutput .= herorow($postsperrow, $postsperrow * $i, "DESC", $sinatra_hero_categories, $theHeroTag);
}
setFunctionTransient(__FUNCTION__,$thisOutput, $atts);
echo $thisOutput;
}
makeHero($postsperrow,$sinatra_hero_categories);
?>
Code formatting
/*
* Plugin Name: MK Custom Shortcodes
* Plugin URI: https://michaelkupietz.com/plugins/the-basics/
* Description: My custom shortcodes.
* Version: 1
* Requires at least: 5.2
* Requires PHP: 7.2
* Author: Michael Kupietz
* Author URI: https://michaelkupietz.com/
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Update URI: https://michaelkupietz.com/my-plugin/
* Text Domain: mk-plugin
* Domain Path: /languages
*/
// first, some useful functions
add_action( 'save_post', 'setLastUpdateTimestamp' );
add_action( 'create_category', 'setLastUpdateTimestamp' );
add_action( 'edited_category', 'setLastUpdateTimestamp' );
add_action( 'delete_category', 'setLastUpdateTimestamp' );
add_action( 'create_post_tag', 'setLastUpdateTimestamp', 10, 2 );
add_action( 'edited_terms', 'setLastUpdateTimestamp', 10, 2 );
add_action( 'delete_term', 'setLastUpdateTimestamp', 10, 3 );
add_action( 'trash_post', 'setLastUpdateTimestamp' );
function setLastUpdateTimestamp() {
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
// If this is a revision, don't do anything.
if ( wp_is_post_revision( $post_id ) ) {
return;
}
set_transient( 'mkFuncTransient_LastUpdateCheck',microtime(true),2592000);
}
function latestPostUpdate() {
$theDate = get_transient( 'mkFuncTransient_LastUpdate_all');
if ( ! (isset($theDate) && $theDate > 0)) {
$theDate=microtime(true);
set_transient( 'mkFuncTransient_LastUpdate_all',$theDate,2592000); //dont expire for 1 month
}
}
return $theDate;
}
function getFunctionTransient ($functionName,$arguments=[]) {
//call with: $transientData = getFunctionTransient(__FUNCTION__,$arguments);
if ( $transientData != null) {return $transientData;}
//the $arguments term in the call is optional. It's only for functions that return different values based on their arguments, so you can cache and retrieve the values of different calls with different arguments.
$data = get_transient( 'mkFuncTransient'.$functionName . hash( 'sha512',json_encode( $arguments ) ) ); //per https://daext.com/blog/improve-the-shortcode-performance-with-the-wordpress-transients-api/
$lastUpdate = get_transient( 'mkFuncTransient_LastUpdate' . $functionName . hash( 'sha512', json_encode( $arguments ) ) );
$lastPostUpdate = latestPostUpdate();
if ( $data !== false and $lastUpdate < $lastPostUpdate ) {
return $data;
} else {
return null;
}
}
function setFunctionTransient ($functionName, $value = null, $arguments=[]) {
//call this right before returning value of function with as: setFunctionTransient(__FUNCTION__,$returnValue,$arguments);
//you can leave out returnvalue, or set it to null, to delete the transient.
//the $arguments term in the call is optional. It's only for functions that return different values based on their arguments, so you can cache and retrieve the values of different calls with different arguments.
if ( $value === null) {
delete_transient( 'mkFuncTransient'.$functionName . hash( 'sha512',json_encode( $arguments ) ) );
delete_transient( 'mkFuncTransient_LastUpdate' . $functionName . hash( 'sha512', json_encode( $arguments ) ));
return $value;
} else {
set_transient( 'mkFuncTransient'.$functionName . hash( 'sha512',json_encode( $arguments ) ) , $value , 2592000); //dont expire for 1 month
set_transient( 'mkFuncTransient_LastUpdate' . $functionName . hash( 'sha512', json_encode( $arguments ) ), latestPostUpdate(), 2592000 );
return $value;
}
}
[ ... the rest of my shortcode routines follow here ... ]
function hierarchicalsitemap_shortcode_htmlmap($atts, $content = null) {
.... a bunch of fancy PHP to generate the menu HTML from the categories goes here ....
$out = [result of above code];
return $out;
}
Audio playlist