: * * * MATCH YOUR TEMPLATE: set HASHTAG_CADE_HEADER + HASHTAG_CADE_FOOTER (your site's partials) or * HASHTAG_CADE_TEMPLATE (one file with + optional ), * and pages render inside your own look and feel. With none set, a clean light+dark shell is used. * * BUILD STATIC PAGES (recommended — like the old SEO plugin, plus ongoing): run a daily cron so new CADE * content becomes new pages automatically: * php /path/to/hashtag.php build (CLI) — writes blog/faq/profiles .php pages into your site * https://your-site.com/hashtag.php?hcb_build=1 (web; add &hcb_key=… if you set HASHTAG_CADE_KEY) * * INSTALL (web builder): copy this file to your web root as `index.php`, point your site root at it, set * HASHTAG_CADE_DOMAIN to the domain whose site you built on hashtag.org. That's it. * * CHECK IT: open https://your-site.com/hashtag.php?hcb_status=1 for a live status readout. * PREVIEW WITHOUT REWRITES: https://your-site.com/hashtag.php?hcb_path=/faq (add &hcb_domain=hashtag.org for demo content) * * Including this file (require/require_once) only DEFINES functions — it never renders on its own. It renders * only when requested as the main script, so the routes above work and a homepage include stays safe. * WHEN AUTO-PREPENDED (`php_value auto_prepend_file /path/hashtag.php` in .htaccess) it ALSO decorates the * site's OWN pages — whatever front controller renders them (BRON / imagehosting / any CMS) — injecting the * footer nav (Services popup + Blog/FAQ/Authors), the self-updating copyright placement, and the GIGI embed * on every HTML page. So one drop-in file lights up the whole site, and /blog /faq /profiles /wp-json still * route to it directly. */ /* Load once per request. As auto_prepend_file we run ahead of the site's front controller, which may also * route /blog etc. back to this very file; this guard makes the second entry a no-op — nothing is defined * or dispatched twice. */ if (defined('HASHTAG_CADE_LOADED')) { return; } define('HASHTAG_CADE_LOADED', 1); /* ============================================================================ * CONFIG — edit these * ========================================================================== */ // Your site's domain as CADE knows it. Leave 'AUTO' to use this site's own hostname. // For testing against hashtag.org's demo content, set 'hashtag.org'. if (!defined('HASHTAG_CADE_DOMAIN')) define('HASHTAG_CADE_DOMAIN', 'AUTO'); // The hub feed. You normally never change this. if (!defined('HASHTAG_CADE_API')) define('HASHTAG_CADE_API', 'https://hashtag.org/api/cade/feed'); // The hub's full-site endpoint (used only in web-builder mode — when this file is named index.php). if (!defined('HASHTAG_CADE_API_SITE')) define('HASHTAG_CADE_API_SITE', 'https://hashtag.org/api/cade/site'); // The BRON / seolocal feed base. When set (baked in for BRON/seolocal clients at download), index.php // (full-plugin) mode serves the site's BRON pages exactly like the seolocal plugin does — POST the request // (domain + Action + PageID + k + …) to {BRON}Articles.php (homepage / service list) or {BRON}Article.php (a // single service / resource page) and return the HTML, decorated with our footer nav + GIGI embed. One file // from us, direct to the feed (no extra hops); only CADE's /wp-json references the hub. LEFT EMPTY, index.php // mode instead serves hashtag.org-built pages (/api/cade/site) — so a web-builder site isn't sent to the feed. if (!defined('HASHTAG_CADE_BRON')) define('HASHTAG_CADE_BRON', ''); // Your tenant host on the hub (the #name's `.hashtag.org`). `/wp-json/*` requests are proxied here so // the CADE dashboard can connect to THIS domain as a WordPress site. Leave AUTO to derive `.hashtag.org`. if (!defined('HASHTAG_CADE_TENANT')) define('HASHTAG_CADE_TENANT', 'AUTO'); // Optional site key (for the liveness/ownership signal). Read access is public, so this can stay blank. if (!defined('HASHTAG_CADE_KEY')) define('HASHTAG_CADE_KEY', ''); // How long (seconds) to cache the feed locally before refetching. 600 = 10 minutes. if (!defined('HASHTAG_CADE_TTL')) define('HASHTAG_CADE_TTL', 180); // Brand shown in the rendered pages' footer line. Set to your business name. if (!defined('HASHTAG_CADE_BRAND')) define('HASHTAG_CADE_BRAND', ''); // Your site's public URL (e.g. https://your-site.com). Needed when BUILDING static pages from the command // line / cron (no web request to detect it from). Leave blank to auto-detect in web requests. if (!defined('HASHTAG_CADE_SITE_URL')) define('HASHTAG_CADE_SITE_URL', ''); // Where the static build writes pages. Leave blank to use this file's own folder (your web root). if (!defined('HASHTAG_CADE_OUTPUT_DIR')) define('HASHTAG_CADE_OUTPUT_DIR', ''); // LOOK & FEEL — wrap rendered pages in YOUR site's template so they match the rest of the site. // Option A: point these at your header/footer partials (PHP or HTML). They are included around the content. if (!defined('HASHTAG_CADE_HEADER')) define('HASHTAG_CADE_HEADER', ''); // e.g. __DIR__ . '/header.php' if (!defined('HASHTAG_CADE_FOOTER')) define('HASHTAG_CADE_FOOTER', ''); // e.g. __DIR__ . '/footer.php' // Option B: point this at one template file containing the placeholders (optional, // in ) and (where the page body goes). if (!defined('HASHTAG_CADE_TEMPLATE')) define('HASHTAG_CADE_TEMPLATE', ''); // Option C (best for a site with no header/footer files, e.g. a BRON/seolocal site): point this at a LIVE // page on the site. The plugin fetches it and injects the CADE content into its
region, so the // pages inherit the site's real header, footer, nav and CSS. Use the homepage URL. if (!defined('HASHTAG_CADE_TEMPLATE_URL')) define('HASHTAG_CADE_TEMPLATE_URL', ''); // If none of the above are set, pages render in a clean built-in (LIGHT) shell. // Body-text size for CADE content — set to the host site's own paragraph size (e.g. its homepage `p` size, // like '1.5rem') so blog/FAQ/author pages read at the same scale as the rest of the site. '' = default. if (!defined('HASHTAG_CADE_FONT_SIZE')) define('HASHTAG_CADE_FONT_SIZE', ''); // Max content width for CADE pages (the centered article column). '' = default 900px. Widen to match how // wide the host site runs its body content (e.g. '1190px'). if (!defined('HASHTAG_CADE_MAX_WIDTH')) define('HASHTAG_CADE_MAX_WIDTH', ''); // CADE FAQ page slug. Preference order: 'faq' first, else 'faqs', else 'frequently-asked-questions' — so we // use the clean /faq when it's free and step aside to /faqs (then the long form) only when the site already // ships its own /faq page (many BRON/seolocal sites do). The per-domain download probes the live site and // bakes the first FREE slug here; this compiled default is just the first choice for a generic download. if (!defined('HASHTAG_CADE_FAQ_SLUG')) define('HASHTAG_CADE_FAQ_SLUG', 'faq'); /* ============================================================================ * Helpers * ========================================================================== */ function hcb_e($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); } /** The domain to ask the hub for. */ function hcb_domain() { $d = HASHTAG_CADE_DOMAIN; if (!empty($_GET['hcb_domain'])) $d = $_GET['hcb_domain']; // test override if ($d === 'AUTO' || $d === '') $d = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : ''; $d = strtolower(preg_replace('#^https?://#', '', trim($d))); return preg_replace('#/.*$#', '', $d); } /** This site's own origin (scheme + host), for building absolute links/canonicals. */ function hcb_self_origin() { $https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443); $host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : hcb_domain(); return ($https ? 'https://' : 'http://') . $host; } function hcb_cache_path($domain) { return rtrim(sys_get_temp_dir(), '/\\') . '/hcb-feed-' . md5($domain) . '.json'; } /** Fetch the full feed for our domain, cached to a temp file with a TTL. Returns a decoded array. */ function hcb_feed() { static $memo = null; if ($memo !== null) return $memo; $domain = hcb_domain(); $cache = hcb_cache_path($domain); if (is_readable($cache) && (time() - filemtime($cache)) < HASHTAG_CADE_TTL) { $raw = file_get_contents($cache); $data = json_decode($raw, true); if (is_array($data)) return $memo = $data; } $url = HASHTAG_CADE_API . '?domain=' . rawurlencode($domain); $raw = hcb_http_get($url); $data = $raw ? json_decode($raw, true) : null; if (is_array($data) && !empty($data['ok'])) { @file_put_contents($cache, $raw); // refresh cache return $memo = $data; } // On failure, fall back to a stale cache if we have one, else an empty feed. if (is_readable($cache)) { $data = json_decode(file_get_contents($cache), true); if (is_array($data)) return $memo = $data; } return $memo = array('ok' => false, 'domain' => $domain, 'blog' => array(), 'faqs' => array(), 'organization' => null); } /** GET a URL via curl, falling back to file_get_contents. Returns the body string, or '' on failure. */ function hcb_http_get($url) { $headers = array('Accept: application/json'); if (HASHTAG_CADE_KEY !== '') $headers[] = 'X-Cade-Key: ' . HASHTAG_CADE_KEY; if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, array( CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 8, CURLOPT_FOLLOWLOCATION => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_USERAGENT => 'hashtag-cade-bridge/1.0', )); $body = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($body !== false && $code >= 200 && $code < 400) return $body; } $ctx = stream_context_create(array('http' => array('timeout' => 8, 'header' => implode("\r\n", $headers)))); $body = @file_get_contents($url, false, $ctx); return $body === false ? '' : $body; } /** POST helper — form-encoded body, HTML response. Used to fetch BRON pages from the feed (Articles.php / * Article.php), exactly like the seolocal plugin's curl POST. Returns '' on any failure. */ function hcb_http_post($url, $params) { $ua = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'hashtag-cade-bridge/1.0'; if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, array( CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $params, CURLOPT_TIMEOUT => 15, CURLOPT_CONNECTTIMEOUT => 7, CURLOPT_FOLLOWLOCATION => true, CURLOPT_USERAGENT => $ua, )); $body = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($body !== false && $code >= 200 && $code < 400) return $body; } $ctx = stream_context_create(array('http' => array( 'method' => 'POST', 'header' => "Content-Type: application/x-www-form-urlencoded\r\nUser-Agent: " . $ua, 'content' => $params, 'timeout' => 15, ))); $body = @file_get_contents($url, false, $ctx); return $body === false ? '' : $body; } function hcb_snippet($html, $max = 160) { $t = trim(preg_replace('/\s+/', ' ', strip_tags($html))); if (strlen($t) <= $max) return $t; $t = substr($t, 0, $max); $t = preg_replace('/\s+\S*$/', '', $t); return $t . '…'; } function hcb_find(&$items, $slug) { foreach ($items as $it) if (isset($it['slug']) && $it['slug'] === $slug) return $it; return null; } /* ============================================================================ * Routing * ========================================================================== */ /** Determine the requested route: array(type, slug). type ∈ blog|faq|home|unknown. */ /** CADE FAQ URL slug (no slashes) — see the HASHTAG_CADE_FAQ_SLUG note above. Default 'faq'. */ function hcb_faq_slug() { $s = trim((string)HASHTAG_CADE_FAQ_SLUG, '/'); return $s !== '' ? $s : 'faq'; } function hcb_route() { $path = ''; if (!empty($_GET['hcb_path'])) $path = $_GET['hcb_path']; // explicit test override elseif (isset($_SERVER['REQUEST_URI'])) $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $path = '/' . trim((string)$path, '/'); $fq = preg_quote(hcb_faq_slug(), '#'); if (preg_match('#^/blog/([^/]+)/?$#', $path, $m)) return array('blog', urldecode($m[1]), $path); if (preg_match('#^/blog/?$#', $path)) return array('blog', '', $path); if (preg_match('#^/' . $fq . '/([^/]+)/?$#', $path, $m)) return array('faq', urldecode($m[1]), $path); if (preg_match('#^/' . $fq . '/?$#', $path)) return array('faq', '', $path); if (preg_match('#^/(?:profiles|authors)/([^/]+)/?$#', $path, $m)) return array('profiles', urldecode($m[1]), $path); if (preg_match('#^/(?:profiles|authors)/?$#', $path)) return array('profiles', '', $path); if ($path === '/') return array('home', '', $path); return array('unknown', '', $path); } /* ============================================================================ * Rendering * ========================================================================== */ /** A hidden, machine-readable liveness signature so hashtag.org can confirm the bridge is live on this site. */ function hcb_signature() { $d = hcb_e(hcb_domain()); return "\n" . ""; } /** Echo the homepage Organization JSON-LD (call inside your homepage ). No-op until CADE injects one. */ function hashtag_cade_org_schema() { $feed = hcb_feed(); if (empty($feed['organization'])) { echo hcb_signature() . "\n"; return; } echo '\n"; echo hcb_signature() . "\n"; } /** The remote, declarative config the hub pushes to control this site's pages (CSS/layout/template/etc). */ function hcb_config() { $feed = hcb_feed(); return (isset($feed['config']) && is_array($feed['config'])) ? $feed['config'] : array(); } function hcb_cfg_str($cfg, $key) { return (isset($cfg[$key]) && is_string($cfg[$key])) ? $cfg[$key] : ''; } /** CSS derived from the remote layout knobs (max width, spacing). Empty when none set. */ function hcb_layout_css($cfg) { $layout = (isset($cfg['layout']) && is_array($cfg['layout'])) ? $cfg['layout'] : array(); $rules = ''; if (!empty($layout['max_width'])) $rules .= '.hcb-wrap{max-width:' . preg_replace('/[^0-9a-z%.\s-]/i', '', $layout['max_width']) . ';}'; if (!empty($layout['content_spacing'])) $rules .= '.hcb-wrap p{margin-bottom:' . preg_replace('/[^0-9a-z%.\s-]/i', '', $layout['content_spacing']) . ';}'; return $rules; } /** Layout-only CSS (no colours), used when wrapping inside the site's own template so content inherits it. */ function hcb_content_css() { // RULE: CADE content must read at the host site's own body-text size. Set HASHTAG_CADE_FONT_SIZE to the // site's paragraph size (e.g. its homepage `p` size) so blog/FAQ/author text matches the rest of the // site; everything smaller (captions, table cells, previews) is `em`-relative so it scales with it. $fs = HASHTAG_CADE_FONT_SIZE !== '' ? HASHTAG_CADE_FONT_SIZE : '1.0625rem'; $mw = HASHTAG_CADE_MAX_WIDTH !== '' ? HASHTAG_CADE_MAX_WIDTH : '900px'; return ' .hcb-wrap{max-width:' . $mw . ';margin:0 auto;padding:1.5rem 1.25rem 3rem;font-size:' . $fs . ' !important;line-height:1.65;} .hcb-wrap h1{font-size:1.9em !important;font-weight:700;line-height:1.25;margin:0 0 1rem;} .hcb-wrap h2{font-size:1.45em !important;font-weight:700;line-height:1.3;margin:2rem 0 .75rem;} .hcb-wrap h3{font-size:1.2em !important;font-weight:700;line-height:1.35;margin:1.6rem 0 .5rem;} .hcb-wrap h4{font-size:1.05em !important;font-weight:700;line-height:1.4;margin:1.3rem 0 .4rem;} .hcb-wrap p{font-size:' . $fs . ' !important;line-height:1.65;margin:0 0 1.1rem;} .hcb-wrap li{font-size:' . $fs . ' !important;} .hcb-wrap td,.hcb-wrap th{font-size:.78em;} .hcb-wrap img{max-width:100%;height:auto;border-radius:8px;} .hcb-crumb{font-size:.66em;opacity:.7;margin:0 0 1rem;} .hcb-cat{font-size:.6em;font-weight:600;letter-spacing:.05em;text-transform:uppercase;opacity:.7;margin:2rem 0 .75rem;} .hcb-list{list-style:none;margin:0 0 1rem;padding:0;} .hcb-list li{margin:0 0 .85rem;} .hcb-list a{display:block;border:1px solid rgba(128,128,128,.3);border-radius:12px;padding:1rem 1.15rem;text-decoration:none;color:inherit;} .hcb-list .q{display:block;font-weight:600;} .hcb-list .a{display:block;margin-top:.4rem;font-size:.8em;opacity:.72;} .hcb-related{margin-top:2.5rem;border-top:1px solid rgba(128,128,128,.3);padding-top:1.5rem;} .hcb-related ul{margin:0;padding-left:1.1rem;} .hcb-back{display:inline-block;margin:2rem 0 0;font-size:.7em;font-weight:400;opacity:.7;} .hcb-profile-head{display:flex;gap:1.25rem;align-items:center;margin:0 0 1.5rem;} .hcb-avatar{width:84px;height:84px;border-radius:50%;object-fit:cover;} /* In-article FAQ section: CADE marks each question as an

under a "Frequently Asked Questions" heading, so it inherits the big article heading style. Render it as a compact FAQ instead — smaller bold questions, the answer indented under each. Targeted by the frequently-asked-questions anchor. */ .hcb-wrap h2[id*="frequently-asked"]~h3{font-size:1.25rem !important;font-weight:700;line-height:1.35;margin:1.6rem 0 .35rem;} .hcb-wrap h2[id*="frequently-asked"]~h3+p{font-size:1.2rem !important;line-height:1.6;margin:.35rem 0 1.2rem;padding-left:1.1rem;border-left:3px solid rgba(0,0,0,.14);}'; } /** Full standalone shell CSS (with light + dark colours) for when no site template is configured. */ function hcb_shell_css() { return ':root{color-scheme:light;}*{box-sizing:border-box;}' . 'body{margin:0;background:#fff;color:#1a1a1a;font:16px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;}' . '.hcb-wrap a{color:#2563eb;}.hcb-foot{margin-top:3rem;padding-top:1.25rem;border-top:1px solid rgba(128,128,128,.2);font-size:.8rem;color:#9ca3af;}' . hcb_content_css(); // CADE content is light-only — no dark variant (rob 2026-06-30). } /** Self-contained page (light + dark). Used when the site has no template configured. */ function hcb_shell($title, $inner, $head, $canonical) { $brand = HASHTAG_CADE_BRAND !== '' ? HASHTAG_CADE_BRAND : hcb_domain(); $year = date('Y'); $can = $canonical !== '' ? '' : ''; return "\n\n\n\n" . "\n" . '' . hcb_e($title) . "\n$can\n\n$head\n" . hcb_signature() . "\n\n" . "\n
\n$inner\n" . "
" . hcb_e($brand) . " · © $year · Content by CADE on hashtag.org
\n" . "
\n\n"; } /** Wrap content in the site's header/footer partials so it inherits the site's look and feel. */ function hcb_header_footer($title, $inner, $head, $canonical) { $out = ''; if (HASHTAG_CADE_HEADER !== '' && is_readable(HASHTAG_CADE_HEADER)) { ob_start(); include HASHTAG_CADE_HEADER; $out .= ob_get_clean(); } $out .= "\n\n$head\n" . hcb_signature() . "\n"; $out .= "
\n$inner\n
\n"; if (HASHTAG_CADE_FOOTER !== '' && is_readable(HASHTAG_CADE_FOOTER)) { ob_start(); include HASHTAG_CADE_FOOTER; $out .= ob_get_clean(); } return $out; } /** Wrap content using a single template string with + markers. */ function hcb_apply_template($tpl, $title, $inner, $head, $canonical) { $can = $canonical !== '' ? '' : ''; $headBlock = '' . hcb_e($title) . "\n$can\n\n$head\n" . hcb_signature(); $contentBlock = "
\n$inner\n
"; if (strpos($tpl, '') !== false) { $tpl = str_replace('', $headBlock, $tpl); } else { $contentBlock = $headBlock . "\n" . $contentBlock; } if (strpos($tpl, '') !== false) { return str_replace('', $contentBlock, $tpl); } return $tpl . "\n" . $contentBlock; } /** Fetch a LIVE page on the site to use as the template (cached). Returns its HTML, or '' on failure. */ function hcb_site_template_html() { static $tpl = null; if ($tpl !== null) return $tpl; $url = HASHTAG_CADE_TEMPLATE_URL; if ($url === 'AUTO' || $url === '') $url = hcb_self_origin() . '/'; $cache = rtrim(sys_get_temp_dir(), '/\\') . '/hcb-tpl-' . md5($url) . '.html'; if (is_readable($cache) && (time() - filemtime($cache)) < 3600) { return $tpl = file_get_contents($cache); } $html = hcb_http_get($url); // Accept any real HTML page as the template (not just ones with
) so Webflow / Wix / custom // sites template too. The wrap function handles both
and no-
layouts. if ($html !== '' && stripos($html, '') !== false) { @file_put_contents($cache, $html); return $tpl = $html; } return $tpl = (is_readable($cache) ? file_get_contents($cache) : ''); } /** Replace the INNER content of the element whose open tag contains $needle (e.g. class="article-content"), * balancing nested
s to find its matching
. Keeps everything before + after intact so the site's * structure and scripts still work. Returns '' if not found. */ function hcb_replace_container($html, $needle, $inner) { $pos = stripos($html, $needle); if ($pos === false) return ''; $tagEnd = strpos($html, '>', $pos); if ($tagEnd === false) return ''; $depth = 1; $i = $tagEnd + 1; $len = strlen($html); while ($i < $len && $depth > 0) { $no = stripos($html, '', $i); if ($nc === false) return ''; if ($no !== false && $no < $nc) { $depth++; $i = $no + 4; } else { $depth--; $i = $nc + 6; } } return substr($html, 0, $tagEnd + 1) . $inner . '' . substr($html, $i); } /** * Wrap the CADE content in the site's real template by injecting it into the fetched page's
region * (replacing that page's own main content). The CADE pages then inherit the site's header, footer, nav and * CSS. Returns '' if the template can't be used (caller falls back to the shell). */ function hcb_template_url_wrap($title, $inner, $head) { $tpl = hcb_site_template_html(); if ($tpl === '') return ''; $tpl = hcb_fix_service_links(hcb_fix_base_href($tpl)); // keep the templated CADE pages' links on this host $content = "\n\n" . $head . "\n
\n" . $inner . "\n
\n"; // Preferred: replace the site's own
region (WordPress / patientpop and anything using
). $mo = stripos($tpl, '', $mo) : false; $mc = stripos($tpl, '
'); if ($mo !== false && $gt !== false && $mc !== false && $mc > $gt) { $before = preg_replace('/.*?<\/title>/is', '<title>' . hcb_e($title) . '', substr($tpl, 0, $gt + 1), 1); return $before . $content . substr($tpl, $mc); } // BRON/CMS stream: replace the inner of the
container (the article body + // its title) with the CADE content. The header (everything above it) and the footer (everything below) // stay fully intact — no page-cutting, so the site's structure + scripts keep working. This is the rule: // "the header is anything above the title of the article." $ac = hcb_replace_container($tpl, 'class="article-content"', $content); if ($ac !== '') return preg_replace('/.*?<\/title>/is', '<title>' . hcb_e($title) . '', $ac, 1); // Fallback for no-
sites (Webflow / Wix / custom): keep the site's + top nav, drop the // homepage body, inject the CADE content, then keep the footer that already comes in the BRON stream // (anchored on the copyright). This auto-templates any site — no hand work. $bo = stripos($tpl, '', $bo) : false; if ($bo === false || $bgt === false) return ''; $head2 = preg_replace('/.*?<\/title>/is', '<title>' . hcb_e($title) . '', substr($tpl, 0, $bgt + 1), 1); $body = substr($tpl, $bgt + 1); // Top = the site's nav: everything up to the first content block (
/
/
). $navEnd = strlen($body); foreach (array(' 0 && $navEnd < 60000) ? substr($body, 0, $navEnd) : ''; // Bottom = the stream's footer: anchor on the copyright, walk back to the nearest wrapping tag so the // whole footer block (copyright + our injected Services/Blog/FAQ/Authors nav) is preserved. $footer = "\n\n"; $cp = strripos($body, '©'); if ($cp !== false) { $winStart = max(0, $cp - 600); $win = substr($body, $winStart, $cp - $winStart); $best = -1; foreach (array(' $best) $best = $c; } if ($best >= 0) { $footer = substr($body, $winStart + $best); if (stripos($footer, '') === false) $footer .= "\n\n"; } } return $head2 . "\n" . $nav . $content . "\n" . $footer; } /** * Build a page: apply the remote config (CSS / before+after HTML / head), then wrap it in the site's * template (remote config template → live site-template URL → local template file → header+footer → shell). */ /** GIGI AI-embed snippet for this site, or '' when the toggle is off (resolved server-side in the feed). */ function hcb_ai_embed_script() { $cfg = hcb_config(); if (isset($cfg['aiEmbed']) && is_array($cfg['aiEmbed']) && !empty($cfg['aiEmbed']['enabled']) && !empty($cfg['aiEmbed']['script'])) { return (string) $cfg['aiEmbed']['script']; } return ''; } /** Render a page, then inject the GIGI AI-embed (voice launcher + Locate-me + portal panel) before * whenever the site's AI-embed toggle is on. One place covers every page the plugin serves. */ function hcb_page($title, $bodyHtml, $headExtra = '', $canonical = '') { $html = hcb_page_render($title, $bodyHtml, $headExtra, $canonical); // Footer nav (Services popup + Blog / FAQ / Authors). When we wrap into the site's own template, some // layouts keep their footer INSIDE the container we replace (e.g. a BRON homepage whose copyright sits // within class="article-content"), so the nav we rely on gets dropped. Re-add it whenever it's missing so // every CADE page stays navigable and consistent with the decorated BRON pages: inject next to the // copyright when the template kept one, otherwise append a small footer before . if (stripos($html, 'hg-foot-nav') === false) { $withNav = hcb_inject_footer_links($html); if (stripos($withNav, 'hg-foot-nav') !== false) { $html = $withNav; } elseif (stripos($html, '') !== false) { $feed = hcb_feed(); $nav = hcb_footer_nav( hcb_build_services($html), hcb_build_recent_menu(isset($feed['blog']) ? $feed['blog'] : array(), 'blog', 5, 'title'), hcb_build_recent_menu(isset($feed['faqs']) ? $feed['faqs'] : array(), 'faq', 10, 'question') ); $foot = '
' . $nav . '
'; $html = preg_replace('/<\/body>/i', $foot . '', $html, 1); } } $embed = hcb_ai_embed_script(); if ($embed !== '' && stripos($html, '') !== false) { $html = preg_replace('/<\/body>/i', $embed . '', $html, 1); } return $html; } function hcb_page_render($title, $bodyHtml, $headExtra = '', $canonical = '') { $cfg = hcb_config(); $head = $headExtra; if (hcb_cfg_str($cfg, 'head_html') !== '') $head .= "\n" . $cfg['head_html']; $extraCss = hcb_layout_css($cfg) . hcb_cfg_str($cfg, 'css'); if ($extraCss !== '') $head .= "\n"; $inner = hcb_cfg_str($cfg, 'before_content') . $bodyHtml . hcb_cfg_str($cfg, 'after_content'); if (hcb_cfg_str($cfg, 'template_html') !== '') return hcb_apply_template($cfg['template_html'], $title, $inner, $head, $canonical); if (HASHTAG_CADE_TEMPLATE_URL !== '') { $w = hcb_template_url_wrap($title, $inner, $head); if ($w !== '') return $w; } if (HASHTAG_CADE_TEMPLATE !== '' && is_readable(HASHTAG_CADE_TEMPLATE)) return hcb_apply_template(file_get_contents(HASHTAG_CADE_TEMPLATE), $title, $inner, $head, $canonical); if (HASHTAG_CADE_HEADER !== '' || HASHTAG_CADE_FOOTER !== '') return hcb_header_footer($title, $inner, $head, $canonical); return hcb_shell($title, $inner, $head, $canonical); } function hcb_jsonld($data) { return ''; } function hcb_render_blog_index() { $feed = hcb_feed(); $items = isset($feed['blog']) ? $feed['blog'] : array(); $origin = hcb_self_origin(); $body = '

Blog

'; if (!$items) { $body .= '

No articles published yet. Check back soon.

'; return hcb_page('Blog', $body, '', $origin . '/blog'); } $body .= ''; $schema = hcb_jsonld(array('@context' => 'https://schema.org', '@type' => 'ItemList', 'itemListElement' => $listEls)); return hcb_page('Blog', $body, $schema, $origin . '/blog'); } function hcb_render_article($slug) { $feed = hcb_feed(); $items = isset($feed['blog']) ? $feed['blog'] : array(); $a = hcb_find($items, $slug); $origin = hcb_self_origin(); if (!$a) return hcb_render_404('Article not found'); $canonical = $origin . '/blog/' . rawurlencode($slug); $body = ''; $body .= '

' . hcb_e($a['title']) . '

'; $body .= $a['html']; $body .= '← All articles'; $schemaObj = $a['jsonld']; // Re-anchor to THIS site's canonical, keeping the feed's rich BlogPosting shape (dates, author + // publisher Organization, image[], articleSection). mainEntityOfPage stays a WebPage object. $schemaObj['url'] = $canonical; $schemaObj['mainEntityOfPage'] = array('@type' => 'WebPage', '@id' => $canonical); return hcb_page($a['title'], $body, hcb_jsonld($schemaObj), $canonical); } function hcb_render_faq_index() { $feed = hcb_feed(); $items = isset($feed['faqs']) ? $feed['faqs'] : array(); $origin = hcb_self_origin(); $body = '

Frequently Asked Questions

'; if (!$items) { $body .= '

No FAQs published yet. Check back soon.

'; return hcb_page('FAQ', $body, '', $origin . '/' . hcb_faq_slug()); } // Group by category (uncategorised last). $groups = array(); $hasCat = false; foreach ($items as $f) { $key = !empty($f['category']) ? $f['category'] : ''; if ($key !== '') $hasCat = true; $groups[$key][] = $f; } if (isset($groups['']) && count($groups) > 1) { $u = $groups['']; unset($groups['']); $groups[''] = $u; } $listEls = array(); $i = 0; foreach ($groups as $cat => $rows) { if ($hasCat) $body .= '

' . hcb_e($cat !== '' ? $cat : 'More questions') . '

'; $body .= ''; } if (!empty($feed['blog'])) $body .= '

Read the blog →

'; $schema = hcb_jsonld(array('@context' => 'https://schema.org', '@type' => 'ItemList', 'itemListElement' => $listEls)); return hcb_page('Frequently Asked Questions', $body, $schema, $origin . '/' . hcb_faq_slug()); } /** Lowercase content tokens (4+ chars, minus stopwords) for FAQ→article topic matching. */ function hcb_topic_tokens($s, $stop) { $s = strtolower($s); $s = preg_replace('/[^a-z0-9]+/', ' ', $s); $out = array(); foreach (explode(' ', $s) as $t) { if (strlen($t) >= 4 && !isset($stop[$t])) $out[$t] = 1; } return $out; } function hcb_render_faq_detail($slug) { $feed = hcb_feed(); $items = isset($feed['faqs']) ? $feed['faqs'] : array(); $f = hcb_find($items, $slug); $origin = hcb_self_origin(); if (!$f) return hcb_render_404('FAQ not found'); $canonical = $origin . '/' . hcb_faq_slug() . '/' . rawurlencode($slug); // Related: same category first, then the rest. Up to 6. $sameCat = array(); $rest = array(); foreach ($items as $o) { if ($o['slug'] === $slug) continue; if (!empty($f['category']) && isset($o['category']) && $o['category'] === $f['category']) $sameCat[] = $o; else $rest[] = $o; } $related = array_slice(array_merge($sameCat, $rest), 0, 6); $body = ''; $body .= '

' . hcb_e($f['question']) . '

'; $body .= $f['html']; if ($related) { $body .= ''; } $body .= '

← All FAQs

'; if (!empty($feed['blog'])) { // CADE drops a FAQ alongside the article on the same topic — link to THAT article, not the blog // index. FAQs often carry no category, so match by shared topic tokens in the slug/text (a matching // category adds a boost). Falls back to /blog when nothing overlaps. $stop = array('what'=>1,'does'=>1,'much'=>1,'near'=>1,'this'=>1,'that'=>1,'with'=>1,'from'=>1,'your'=>1,'will'=>1,'have'=>1,'been'=>1,'cost'=>1,'guide'=>1,'complete'=>1); $ftoks = hcb_topic_tokens((isset($f['slug']) ? $f['slug'] : '') . ' ' . (isset($f['question']) ? $f['question'] : ''), $stop); $fcat = !empty($f['category']) ? $f['category'] : ''; $srcSlug = ''; $bestScore = 0; foreach ($feed['blog'] as $b) { if (empty($b['slug'])) continue; $btoks = hcb_topic_tokens($b['slug'] . ' ' . (isset($b['title']) ? $b['title'] : ''), $stop); $score = count(array_intersect_key($ftoks, $btoks)); if ($fcat !== '' && isset($b['category']) && $b['category'] === $fcat) $score += 5; if ($score > $bestScore) { $bestScore = $score; $srcSlug = $b['slug']; } } if ($srcSlug !== '') { $body .= '

Read the full article →

'; } else { $body .= '

Read more on the blog →

'; } } $schema = array('@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => array(array( '@type' => 'Question', 'name' => $f['question'], 'url' => $canonical, 'acceptedAnswer' => array('@type' => 'Answer', 'text' => isset($f['text']) ? $f['text'] : strip_tags($f['html'])), ))); return hcb_page($f['question'], $body, hcb_jsonld($schema), $canonical); } function hcb_render_profiles_index() { $feed = hcb_feed(); $items = isset($feed['profiles']) ? $feed['profiles'] : array(); $origin = hcb_self_origin(); $body = '

Authors

'; if (!$items) { $body .= '

No author profiles published yet.

'; return hcb_page('Authors', $body, '', $origin . '/profiles'); } $body .= ''; return hcb_page('Authors', $body, '', $origin . '/profiles'); } function hcb_render_profile($slug) { $feed = hcb_feed(); $items = isset($feed['profiles']) ? $feed['profiles'] : array(); $p = hcb_find($items, $slug); $origin = hcb_self_origin(); if (!$p) return hcb_render_404('Profile not found'); $canonical = $origin . '/profiles/' . rawurlencode($slug); $body = ''; $body .= '
'; if (!empty($p['headshot'])) $body .= '' . hcb_e($p['name']) . ''; $body .= '

' . hcb_e($p['name']) . '

' . (!empty($p['headline']) ? '

' . hcb_e($p['headline']) . '

' : '') . '
'; if (!empty($p['bio'])) foreach ($p['bio'] as $para) $body .= '

' . hcb_e($para) . '

'; if (!empty($p['expertise'])) { $body .= '

Expertise: ' . hcb_e(implode(', ', $p['expertise'])) . '

'; } $meta = array(); if (!empty($p['location'])) $meta[] = hcb_e($p['location']); if (!empty($p['website'])) $meta[] = 'Website'; if (!empty($p['social'])) foreach ($p['social'] as $s) $meta[] = '' . hcb_e($s['label']) . ''; if ($meta) $body .= '

' . implode(' · ', $meta) . '

'; $body .= '

← All authors

'; $ld = !empty($p['jsonld']) ? $p['jsonld'] : null; if ($ld) $ld['url'] = $canonical; // anchor the Person URL to this host, not the content origin $schema = $ld ? hcb_jsonld($ld) : ''; return hcb_page($p['name'], $body, $schema, $canonical); } /** Web-builder mode homepage: a full landing page composed from this site's CADE content. */ function hcb_render_home() { $feed = hcb_feed(); $origin = hcb_self_origin(); $brand = HASHTAG_CADE_BRAND !== '' ? HASHTAG_CADE_BRAND : hcb_domain(); $blog = isset($feed['blog']) ? $feed['blog'] : array(); $faqs = isset($feed['faqs']) ? $feed['faqs'] : array(); $profiles = isset($feed['profiles']) ? $feed['profiles'] : array(); $body = '

' . hcb_e($brand) . '

'; if ($blog) { $body .= '

Latest articles

All articles →

'; } if ($faqs) { $body .= '

Frequently asked questions

All FAQs →

'; } if ($profiles) { $body .= '

Authors

'; } if (!$blog && !$faqs && !$profiles) $body .= '

This site is connected to hashtag.org. Content will appear here as CADE publishes it.

'; $head = !empty($feed['organization']) ? hcb_jsonld($feed['organization']) : ''; return hcb_page($brand, $body, $head, $origin . '/'); } function hcb_render_404($msg) { http_response_code(404); return hcb_page('Not found', '

' . hcb_e($msg) . '

Browse FAQs · Browse the blog

'); } /** Render one route (type ∈ blog|faq|profiles|home) directly — used by the generated static .php stubs. */ function hashtag_cade_render_route($type, $slug = '') { header('Content-Type: text/html; charset=utf-8'); if ($type === 'blog') { echo $slug !== '' ? hcb_render_article($slug) : hcb_render_blog_index(); return; } if ($type === 'faq') { echo $slug !== '' ? hcb_render_faq_detail($slug): hcb_render_faq_index(); return; } if ($type === 'profiles') { echo $slug !== '' ? hcb_render_profile($slug) : hcb_render_profiles_index(); return; } echo hcb_render_home(); } /** True when this file is being used as a full-site front controller (renamed to index.php, or forced). */ function hcb_is_site_mode() { if (defined('HASHTAG_CADE_SITE_MODE') && HASHTAG_CADE_SITE_MODE) return true; return basename(__FILE__) === 'index.php'; } /** * Web-builder mode: this file is index.php, the front controller for the WHOLE site. We try to stream the * page hashtag.org's web builder produced for this exact path; if the builder has no page for it, we fall * back to rendering the CADE-composed site (home / blog / faq / profiles) so the site is never blank. */ /** Build a "Services" hover dropdown from a page's seolocal service-page links (Action=1&k=). The links stay * in the DOM so search engines still crawl every service page linked from the homepage; the dropdown just * presents them cleanly beside the footer nav instead of a popup that overlaps the copyright. */ /** Extract de-duped BRON service-page links from a page's HTML → array(href => label). */ function hcb_extract_service_links($html) { $out = array(); if (preg_match_all('#]*href="([^"]*\?Action=1&(?:k|PageID)=[^"]*)"[^>]*>([^<]+)#i', $html, $m, PREG_SET_ORDER)) { foreach ($m as $a) { $href = $a[1]; $label = trim($a[2]); if ($label === '') continue; // Normalize to a root "/" (index.php) path. The CADE .php file serves BRON service pages // WITHOUT the footer nav, so a menu link pointing at .php?Action=1 dead-ends with no menu; // "/" (web-builder index.php) serves the same content WITH the footer + Services dropdown. $href = preg_replace('#^.*?(?=\?Action=1)#i', '/', $href, 1); if (isset($out[$href])) continue; $out[$href] = $label; } } return $out; } /** The site's FULL service-page set, accumulated across page views in a per-domain cache. A single service * or blog page only links to a SUBSET of the BRON keywords; the homepage links to all of them. Unioning * every page's links into a cache means the footer Services menu grows to the complete set and stays * consistent on every page (no more "18 here, 30 on the homepage"). */ function hcb_services_union($html) { $cur = hcb_extract_service_links($html); $domain = isset($_SERVER['HTTP_HOST']) ? preg_replace('/:\d+$/', '', $_SERVER['HTTP_HOST']) : ''; if ($domain === '') return $cur; $cache = rtrim(sys_get_temp_dir(), '/\\') . '/hg-svc2-' . md5($domain) . '.json'; $cached = array(); if (is_readable($cache)) { $j = json_decode(file_get_contents($cache), true); if (is_array($j)) $cached = $j; } $merged = $cached; foreach ($cur as $href => $label) { if (!isset($merged[$href])) $merged[$href] = $label; } if (count($merged) > count($cached)) { @file_put_contents($cache, json_encode($merged)); } return $merged; } function hcb_build_services($html) { $idxHref = '?Action=1'; if (preg_match('#href="([^"]*\?Action=1)"#i', $html, $im)) { $idxHref = $im[1]; } $links = hcb_services_union($html); // The trigger is a hover/click popup — NOT a link to the keyword index page (the service-page links live // inside the popup, still crawlable). If we somehow found no links, fall back to the index link. if (empty($links)) return 'Services'; asort($links, SORT_NATURAL | SORT_FLAG_CASE); // stable alphabetical order on every page $items = ''; foreach ($links as $href => $label) { // Each BRON keyword has a companion "Resources" page = same URL with Action=1 → Action=2 (the site // labels it " - Resources"). Append it as a muted secondary link, like hashtag.org's menu. $res = str_replace('Action=1', 'Action=2', $href); $items .= '' . $label . 'Resources'; } // Two-column popup only when the list is long enough to warrant it; a short menu reads better single-column. $cols = count($links) > 12 ? ' hg-svc-cols' : ''; return 'Services
' . $items . '
'; } /** Build the N most-recent items (blog/FAQ) as real links for a footer hover dropdown — crawlable by * Google + LLMs whether the menu is open or not (it's display:none on hover, the links stay in the DOM). */ function hcb_build_recent_menu($items, $type, $limit, $labelKey) { if (!is_array($items)) return ''; $out = ''; $n = 0; foreach ($items as $it) { if ($n >= $limit) break; $slug = isset($it['slug']) ? trim((string) $it['slug']) : ''; $label = isset($it[$labelKey]) ? trim((string) $it[$labelKey]) : ''; if ($slug === '' || $label === '') continue; $out .= '' . htmlspecialchars($label, ENT_QUOTES) . ''; $n++; } return $out; } /** A footer link that's also a hover/focus dropdown of recent items. Falls back to a plain link if empty. */ function hcb_recent_dropdown($indexHref, $label, $menuHtml) { if ($menuHtml === '') return '' . $label . ''; return '' . $label . '
' . $menuHtml . '
'; } /** Inject the footer nav (Services dropdown + Blog / FAQ / Authors) beside the self-updating copyright, on * every BRON/web-builder page the plugin serves. No-op (returns $html) when there's no copyright line. */ function hcb_footer_nav($services, $blogMenu, $faqMenu) { // Popup is position:FIXED + JS-positioned so a parent with overflow:hidden can't clip it (the CSS-only // absolute version was getting clipped by the footer container). Links stay in the DOM = crawlable. $css = ''; $js = ''; // One evenly-spaced, non-wrapping row: Services · Blog · FAQ · Authors (Authors stays on the row, at the // end) — a flex container replaces the   separators that broke unevenly and wrapped Authors below. $nav = $services . hcb_recent_dropdown('/blog', 'Blog', $blogMenu) . hcb_recent_dropdown('/' . hcb_faq_slug(), 'FAQ', $faqMenu) . 'Authors'; return $css . '' . $nav . '' . $js; } function hcb_inject_footer_links($html) { $feed = hcb_feed(); $nav = hcb_footer_nav( hcb_build_services($html), hcb_build_recent_menu(isset($feed['blog']) ? $feed['blog'] : array(), 'blog', 5, 'title'), hcb_build_recent_menu(isset($feed['faqs']) ? $feed['faqs'] : array(), 'faq', 10, 'question') ); // Inject AFTER the copyright's closing
*and* any wrapping — many site footers wrap the // copyright line in , so injecting before would nest the whole nav inside that // link and every click (Services/Blog/FAQ) would navigate home instead of opening the menu. $tmp = @preg_replace('/(©\s*\d{4}[^<]*?<\/div>(?:\s*<\/a>)?)/', '$1' . $nav, $html, 1); return (is_string($tmp) && $tmp !== '') ? $tmp : $html; } /** The seolocal BRON stream can carry a pointing at the account's APEX domain (e.g. * https://arthomeclean.com/), which sends every root-relative link (our /blog, /faq, /authors) to the wrong * host. Repoint it at the host actually serving this page so everything stays on this subdomain. */ function hcb_fix_base_href($html) { if (!is_string($html) || stripos($html, ']*\bhref\s*=\s*("|\')[^"\']*\1[^>]*>#i', '', $html, 1); } /** In index.php (front-controller) mode the feed still emits its service links with ITS own plugin filename * (e.g. /cleaning.php?Action=1…), which 404s here. Repoint those ?Action= links at index.php so they route * through our BRON proxy on this same host — never off to another file or the apex domain. */ function hcb_fix_service_links($html) { if (basename(__FILE__) !== 'index.php' || !is_string($html)) return $html; return preg_replace('#/[A-Za-z0-9_-]+\.php(\?Action=)#i', '/index.php$1', $html); } /** First Organization/LocalBusiness node in any JSON-LD block already on the page (unwraps @graph), or null. */ function hcb_find_existing_org($html) { if (!preg_match_all('#]*application/ld\+json[^>]*>(.*?)#is', (string) $html, $m)) return null; foreach ($m[1] as $raw) { $j = json_decode(trim($raw), true); if (!is_array($j)) continue; $nodes = (isset($j['@graph']) && is_array($j['@graph'])) ? $j['@graph'] : array($j); foreach ($nodes as $n) { if (!is_array($n) || !isset($n['@type'])) continue; $t = is_array($n['@type']) ? implode(' ', $n['@type']) : (string) $n['@type']; if (stripos($t, 'Organization') !== false || stripos($t, 'LocalBusiness') !== false) return $n; } } return null; } /** Drop every Organization/LocalBusiness JSON-LD block from $html (keeps all other structured data intact). */ function hcb_strip_org_blocks($html) { return preg_replace_callback('#]*application/ld\+json[^>]*>(.*?)#is', function ($mm) { $j = json_decode(trim($mm[1]), true); if (is_array($j)) { $nodes = (isset($j['@graph']) && is_array($j['@graph'])) ? $j['@graph'] : array($j); foreach ($nodes as $n) { if (!is_array($n) || !isset($n['@type'])) continue; $t = is_array($n['@type']) ? implode(' ', $n['@type']) : (string) $n['@type']; if (stripos($t, 'Organization') !== false || stripos($t, 'LocalBusiness') !== false) return ''; } } return $mm[0]; }, (string) $html); } /** * The CADE Organization JSON-LD as a '; } /** Footer links + GIGI AI-embed for a streamed BRON/web-builder page (these bypass hcb_page). */ function hcb_decorate_site_html($html) { $html = hcb_fix_base_href($html); $html = hcb_fix_service_links($html); $html = hcb_inject_footer_links($html); // CADE Organization JSON-LD (the dashboard's "Inject Organization schema") is AUTHORITATIVE: it carries the // real business NAP. Enrich it with logo/sameAs/image from any Organization the streamed page already holds, // drop that partial/foreign block so the page has exactly ONE org, then inject the CADE one. When CADE has no // org for this domain, hcb_org_ldjson() returns '' and we leave the page's own schema untouched. $org = hcb_org_ldjson($html); if ($org !== '') { $html = hcb_strip_org_blocks($html); if (stripos($html, '') !== false) $html = preg_replace('/<\/head>/i', $org . '', $html, 1); elseif (stripos($html, '') !== false) $html = preg_replace('/<\/body>/i', $org . '', $html, 1); } $embed = hcb_ai_embed_script(); if ($embed !== '' && stripos($html, '') !== false) { $html = preg_replace('/<\/body>/i', $embed . '', $html, 1); } return $html; } /** ob_start callback used in auto-prepend mode: decorate the site's OWN rendered page (BRON / imagehosting / * any CMS) with the footer nav + GIGI embed. Only touches full HTML documents, and is idempotent (skips a * page we already decorated). Never throws away the page: on any miss it returns the original bytes. */ function hcb_autodecorate($html) { if (!is_string($html) || $html === '') return $html; if (stripos($html, '') === false) return $html; // not a full HTML page (JSON, redirect, partial) if (stripos($html, 'hg-foot-nav') !== false) return $html; // already decorated this request try { $out = hcb_decorate_site_html($html); return (is_string($out) && $out !== '') ? $out : $html; } catch (\Throwable $e) { return $html; // never break the client's page over decoration } } function hcb_handle_site() { list($type, $slug, $path) = hcb_route(); // CADE surfaces render directly (blog / faq / authors from the hub feed, in the site template). if ($type === 'blog' || $type === 'faq' || $type === 'profiles') { hashtag_cade_render_route($type, $slug); return; } // Everything else = a BRON page (the homepage + ?Action=/PageID=/k= service + resource pages). // Proxy it from the hub (which forwards to the BRON generator) and decorate it. if (HASHTAG_CADE_BRON !== '') { hcb_bron_proxy(); return; } // No BRON endpoint set — fall back to a hub-built static page, then the CADE home shell. $built = hcb_http_get(HASHTAG_CADE_API_SITE . '?domain=' . rawurlencode(hcb_domain()) . '&path=' . rawurlencode($path)); if ($built !== '') { $page = json_decode($built, true); if (is_array($page) && !empty($page['ok']) && !empty($page['html'])) { header('Content-Type: text/html; charset=utf-8'); echo hcb_decorate_site_html($page['html']); return; } } if ($type === 'home') { header('Content-Type: text/html; charset=utf-8'); echo hcb_render_home(); return; } echo hcb_render_404('Page not found'); } /** Proxy a BRON page from the hub + decorate it. Replicates the seolocal plugin's Articles.php/Article.php * proxy (Action='' → homepage / service list; else → a single service or resource page), but points at * hashtag.org so the client depends only on us. Never blanks the site: on an empty response it falls back * to the CADE home shell. */ function hcb_bron_proxy() { $clean = function ($v) { return preg_replace('/[<>"\'\/]/', '', (string) $v); }; $action = isset($_REQUEST['Action']) ? $clean($_REQUEST['Action']) : ''; $params = http_build_query(array( 'domain' => hcb_domain(), 'Action' => $action, 'agent' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '', 'pageid' => isset($_REQUEST['PageID']) ? $clean($_REQUEST['PageID']) : '', 'k' => isset($_REQUEST['k']) ? (string) $_REQUEST['k'] : '', 'referer' => isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '', 'address' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '', 'query' => isset($_SERVER['QUERY_STRING']) ? $_SERVER['QUERY_STRING'] : '', 'uri' => isset($_SERVER['SCRIPT_NAME']) ? $_SERVER['SCRIPT_NAME'] : '', 'cScript' => 'php', 'version' => '2.4', 'page' => isset($_REQUEST['page']) ? $clean($_REQUEST['page']) : '1', 'city' => isset($_REQUEST['city']) ? (string) $_REQUEST['city'] : '', 'cty' => isset($_REQUEST['cty']) ? (string) $_REQUEST['cty'] : '', 'state' => isset($_REQUEST['state']) ? (string) $_REQUEST['state'] : '', 'st' => isset($_REQUEST['st']) ? (string) $_REQUEST['st'] : '', )); // Homepage / service list vs a single service or resource page — the seolocal plugin's split. $endpoint = ($action === '') ? 'Articles.php' : 'Article.php'; $html = hcb_http_post(rtrim(HASHTAG_CADE_BRON, '/') . '/' . $endpoint, $params); header('Content-Type: text/html; charset=utf-8'); if (!is_string($html) || trim($html) === '') { echo hcb_render_home(); return; } echo hcb_decorate_site_html($html); } /* ============================================================================ * WordPress-emulation proxy — lets the CADE dashboard CONNECT to this domain. * The dashboard expects a WordPress site (`/wp-json/*` + the cade-seo plugin). * We forward those requests to the hub's emulation under this site's #name * tenant, so the dashboard sees this domain as a healthy, CADE-compatible * WordPress site and publishes content there (which this plugin then renders). * ========================================================================== */ /** The tenant host on the hub for this site (`<#name>.hashtag.org`). */ function hcb_tenant_host() { if (defined('HASHTAG_CADE_TENANT') && HASHTAG_CADE_TENANT !== '' && HASHTAG_CADE_TENANT !== 'AUTO') return HASHTAG_CADE_TENANT; $label = explode('.', hcb_domain()); $name = isset($label[0]) && $label[0] !== '' ? $label[0] : 'hashtag'; return $name . '.hashtag.org'; } /** Reconstruct the inbound Authorization header (Apache often hides it from PHP). */ function hcb_auth_header() { if (!empty($_SERVER['HTTP_AUTHORIZATION'])) return $_SERVER['HTTP_AUTHORIZATION']; if (!empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) return $_SERVER['REDIRECT_HTTP_AUTHORIZATION']; if (function_exists('apache_request_headers')) { $h = apache_request_headers(); foreach ($h as $k => $v) if (strtolower($k) === 'authorization') return $v; } if (isset($_SERVER['PHP_AUTH_USER'])) { return 'Basic ' . base64_encode($_SERVER['PHP_AUTH_USER'] . ':' . (isset($_SERVER['PHP_AUTH_PW']) ? $_SERVER['PHP_AUTH_PW'] : '')); } return ''; } /** Transparently proxy a /wp-json/* request to the tenant host on the hub and relay the response. */ function hcb_proxy_wpjson() { $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $qs = (isset($_SERVER['QUERY_STRING']) && $_SERVER['QUERY_STRING'] !== '') ? '?' . $_SERVER['QUERY_STRING'] : ''; $url = 'https://' . hcb_tenant_host() . $path . $qs; $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper($_SERVER['REQUEST_METHOD']) : 'GET'; $headers = array('Accept: application/json'); $auth = hcb_auth_header(); if ($auth !== '') $headers[] = 'Authorization: ' . $auth; if (!empty($_SERVER['CONTENT_TYPE'])) $headers[] = 'Content-Type: ' . $_SERVER['CONTENT_TYPE']; if (!is_callable('curl_init')) { http_response_code(502); echo '{"error":"curl unavailable"}'; return; } $ch = curl_init($url); curl_setopt_array($ch, array( CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HEADER => true, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => $headers, CURLOPT_USERAGENT => 'hashtag-cade-bridge/1.0 (wp-json proxy)', )); if ($method !== 'GET' && $method !== 'HEAD') { curl_setopt($ch, CURLOPT_POSTFIELDS, file_get_contents('php://input')); } $resp = curl_exec($ch); if ($resp === false) { http_response_code(502); header('Content-Type: application/json'); echo '{"error":"proxy failed"}'; curl_close($ch); return; } $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $hsize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); curl_close($ch); $rbody = substr($resp, $hsize); http_response_code($code ? $code : 200); if (preg_match('/^Content-Type:\s*(.+?)\r?$/im', substr($resp, 0, $hsize), $m)) header('Content-Type: ' . trim($m[1])); else header('Content-Type: application/json'); echo $rbody; } /** SEO-plugin mode: render the requested CADE sub-page (/blog, /faq, /profiles). */ function hashtag_cade_handle() { if (hcb_is_site_mode()) { hcb_handle_site(); return; } list($type, $slug) = hcb_route(); if ($type === 'blog' || $type === 'faq' || $type === 'profiles') { hashtag_cade_render_route($type, $slug); return; } // A bare hit on the SEO plugin (no sub-path) — show the FAQ index so it isn't blank. hashtag_cade_render_route('faq', ''); } /* ============================================================================ * Static build — write a .php page per blog/FAQ/profile so they exist as real * URLs on this site and new CADE content becomes new pages on each run. * ========================================================================== */ function hcb_build_dir() { $dir = HASHTAG_CADE_OUTPUT_DIR !== '' ? HASHTAG_CADE_OUTPUT_DIR : dirname(__FILE__); return rtrim($dir, '/\\'); } /** Write one generated .php stub that renders a route via this bridge. */ function hcb_write_stub($relPath, $type, $slug, &$report) { $out = hcb_build_dir() . '/' . ltrim($relPath, '/'); $dir = dirname($out); if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { $report['errors'][] = "mkdir failed: $dir"; return; } $bridge = str_replace("'", "\\'", realpath(__FILE__)); $s = addslashes($slug); $php = " array(), 'errors' => array()); $blog = isset($feed['blog']) ? $feed['blog'] : array(); $faqs = isset($feed['faqs']) ? $feed['faqs'] : array(); $profiles = isset($feed['profiles']) ? $feed['profiles'] : array(); if ($blog) { hcb_write_stub('blog/index.php', 'blog', '', $report); foreach ($blog as $a) hcb_write_stub('blog/' . $a['slug'] . '/index.php', 'blog', $a['slug'], $report); } if ($faqs) { hcb_write_stub('faq/index.php', 'faq', '', $report); foreach ($faqs as $f) hcb_write_stub('faq/' . $f['slug'] . '/index.php', 'faq', $f['slug'], $report); } if ($profiles) { hcb_write_stub('profiles/index.php', 'profiles', '', $report); foreach ($profiles as $p) hcb_write_stub('profiles/' . $p['slug'] . '/index.php', 'profiles', $p['slug'], $report); } return $report; } /** Plain-text status the operator (or hashtag.org) can read to confirm the bridge is live + configured. */ function hashtag_cade_status() { $feed = hcb_feed(); $cfg = hcb_config(); header('Content-Type: text/plain; charset=utf-8'); $lines = array( 'hashtag.org CADE bridge v1.0.0', 'mode: ' . (hcb_is_site_mode() ? 'web-builder (index.php)' : 'seo-plugin'), 'domain: ' . hcb_domain(), 'feed ok: ' . (!empty($feed['ok']) ? 'yes' : 'no'), 'package (CADE subscription): ' . (!empty($feed['subscribed']) ? 'active' : 'not active — content unlocks with a package'), 'blog: ' . count(isset($feed['blog']) ? $feed['blog'] : array()), 'faqs: ' . count(isset($feed['faqs']) ? $feed['faqs'] : array()), 'profiles: ' . count(isset($feed['profiles']) ? $feed['profiles'] : array()), 'organization schema: ' . (!empty($feed['organization']) ? 'present' : 'none'), 'remote config keys: ' . ($cfg ? implode(', ', array_keys($cfg)) : '(none)'), ); return implode("\n", $lines) . "\n"; } /* ============================================================================ * .htaccess self-install — merge our rewrite routes into the site's own .htaccess * on first run, keeping every existing rule. This is why the plugin is a true * one-file drop-in: Apache forbids serving .htaccess over HTTP, so the merge must * happen here, on the site, where the file actually lives and is writable. * ========================================================================== */ /** The managed rewrite block we keep between BEGIN/END markers inside the site's .htaccess. */ function hcb_htaccess_block() { $fq = preg_replace('/[^a-z0-9-]/i', '', hcb_faq_slug()); if ($fq === '') $fq = 'faq'; return "# BEGIN hashtag.org CADE\n" . "# Routes CADE surfaces (blog / FAQ / authors) + the CADE dashboard (wp-json) to this plugin and\n" . "# passes the Authorization header through. Auto-managed: lines between these markers are rewritten\n" . "# on the plugin's next run. Any other rules you have above or below are left untouched.\n" . '' . "\n" . 'RewriteEngine On' . "\n" . 'RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]' . "\n" . 'RewriteCond %{REQUEST_FILENAME} !-f' . "\n" . 'RewriteCond %{REQUEST_FILENAME} !-d' . "\n" . 'RewriteRule ^wp-json(/.*)?$ /index.php [L,QSA]' . "\n" . 'RewriteCond %{REQUEST_FILENAME} !-f' . "\n" . 'RewriteCond %{REQUEST_FILENAME} !-d' . "\n" . 'RewriteRule ^(blog|' . $fq . '|profiles|authors)(/.*)?$ /index.php [L,QSA]' . "\n" . '' . "\n" . "# END hashtag.org CADE\n"; } /** Merge our managed block into $existing .htaccess text: replace a prior block in place, else append. */ function hcb_htaccess_merged($existing) { $block = hcb_htaccess_block(); $count = 0; $merged = preg_replace('/# BEGIN hashtag\.org CADE.*?# END hashtag\.org CADE\n?/s', $block, (string) $existing, 1, $count); if (!$count) $merged = ($existing === '' ? '' : rtrim((string) $existing, "\n") . "\n\n") . $block; return $merged; } /** * Self-install our routes into ./.htaccess on first run — front-controller (index.php) mode only, and only * when writable. Idempotent (skips when our current block is already present) and append-only (never removes * the site's own rules), so it can't break the client's site. On a read-only docroot this silently no-ops; * the operator can then grab the merged file from /?hcb_htaccess=1 and upload it by hand. */ function hcb_ensure_htaccess() { if (basename(__FILE__) !== 'index.php') return; $file = dirname(__FILE__) . '/.htaccess'; $existing = @is_file($file) ? (string) @file_get_contents($file) : ''; if (strpos($existing, hcb_htaccess_block()) !== false) return; // already current (incl. faq slug) $canWrite = @is_writable($file) || (!@is_file($file) && @is_writable(dirname($file))); if (!$canWrite) return; @file_put_contents($file, hcb_htaccess_merged($existing), LOCK_EX); } /* ============================================================================ * Entry points — CLI build, web build/status triggers, and request rendering. * Only fires when this file is the script being run (not when included). * ========================================================================== */ if (!defined('HASHTAG_CADE_EMBED')) { $hcbCli = (php_sapi_name() === 'cli'); $self = isset($_SERVER['SCRIPT_FILENAME']) ? realpath($_SERVER['SCRIPT_FILENAME']) : ''; $isMain = $hcbCli || ($self !== '' && $self === realpath(__FILE__)); $hcbReqPath = isset($_SERVER['REQUEST_URI']) ? parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) : ''; if ($isMain) { if (!$hcbCli) hcb_ensure_htaccess(); // self-install our rewrite routes, preserving the site's own rules if (!$hcbCli && strpos((string) $hcbReqPath, '/wp-json') === 0) { // CADE dashboard connecting to this domain as a WordPress site — proxy to the hub tenant. hcb_proxy_wpjson(); } elseif ($hcbCli) { $cmd = isset($argv[1]) ? $argv[1] : ''; if ($cmd === 'build') { $r = hashtag_cade_build(); echo "Built " . count($r['written']) . " page(s).\n"; if ($r['errors']) echo "Errors:\n " . implode("\n ", $r['errors']) . "\n"; } else echo hashtag_cade_status(); } elseif (isset($_GET['hcb_status'])) { echo hashtag_cade_status(); } elseif (isset($_GET['hcb_htaccess'])) { // The merged .htaccess (the site's existing rules + our managed block) — for manual install if the // docroot is read-only and the auto-merge above couldn't write. header('Content-Type: text/plain; charset=utf-8'); $hcbHt = dirname(__FILE__) . '/.htaccess'; echo hcb_htaccess_merged(@is_file($hcbHt) ? (string) @file_get_contents($hcbHt) : ''); } elseif (isset($_GET['hcb_build'])) { // Web build trigger. If a site key is set, require it to match (so randoms can't trigger writes). if (HASHTAG_CADE_KEY !== '' && (!isset($_GET['hcb_key']) || $_GET['hcb_key'] !== HASHTAG_CADE_KEY)) { http_response_code(403); header('Content-Type: text/plain'); echo "Forbidden: hcb_key required.\n"; } else { $r = hashtag_cade_build(); header('Content-Type: text/plain; charset=utf-8'); echo "Built " . count($r['written']) . " page(s).\n" . implode("\n", $r['written']) . "\n"; if ($r['errors']) echo "Errors:\n " . implode("\n ", $r['errors']) . "\n"; } } else { hashtag_cade_handle(); } } elseif (!$hcbCli) { // Auto-decorate mode: auto_prepend'd ahead of the site's own front controller (BRON / imagehosting / // any CMS). Buffer its output and inject the footer nav (Services popup + Blog/FAQ/Authors) + // self-updating copyright placement + GIGI embed on every HTML page. Our own surfaces // (/blog /faq /profiles /wp-json) are served directly when $isMain, so skip them here. $hcbP = (string) $hcbReqPath; $hcbSkip = (strpos($hcbP, '/wp-json') === 0) || preg_match('#^/(blog|' . preg_quote(hcb_faq_slug(), '#') . '|profiles|authors)(/|$)#i', $hcbP); if (!$hcbSkip && !defined('HASHTAG_CADE_NO_AUTODECORATE')) { ob_start('hcb_autodecorate'); } } }