/**
* @param int $threadId
* @param int $page 1-based page within the thread
*
* @return array|null ['html'=>string, 'cssUrl'=>string|null,
* 'jsUrls'=>string[], 'inlineJs'=>array] or null.
*/
public function render(int $threadId, int $page = 1, int $perPage = 0): ?array
{
if ($threadId < 1)
{
return null;
}
try
{
$app = $this->app;
/** @var Thread $thread */
$thread = $app->em()->find('XF:Thread', $threadId);
if (!$thread)
{
\XF::logError('[PostArea] thread ' . $threadId . ' not found');
return null;
}
if (!$thread->canView())
{
$visitor = \XF::visitor();
\XF::logError('[PostArea] thread ' . $threadId . ' not viewable by current visitor'
. ' (user_id=' . $visitor->user_id
. ', node_id=' . $thread->node_id
. ', discussion_state=' . $thread->discussion_state . ').'
. ' The embed renders as the XF visitor; on a WP page the board session cookie'
. ' (path /community) may not be sent, so the visitor is a guest. Check the'
. ' Unregistered/Guest "View node" + "View thread content" permission on that forum.');
return null;
}
$page = max(1, $page);
// Optional per-page override so the embed paginates with shorter
// pages on the WP article. The stock thread controller reads this
// shared (per-request) options object; restored after renderView.
$opts = $app->options();
$origPerPage = ($perPage > 0) ? ($opts->messagesPerPage ?? null) : null;
if ($perPage > 0)
{
$opts->messagesPerPage = $perPage;
}
// Render in the SAME style the surrounding WP page uses (process_content
// renders PAGE_CONTAINER with the visitor's style), so the embed blends
// in. (Avoids $thread->Forum->effective_style_id — not a getter on the
// Forum entity, it lives on its Node — which logged "unknown getter" on
// every render.)
$styleId = (int) (\XF::visitor()->style_id ?: ($app->options()->defaultStyleId ?: 1));
$style = $app->style($styleId ?: 1);
$language = $app->userLanguage(\XF::visitor());
$templater = $app->templater();
$templater->setStyle($style);
$templater->setLanguage($language);
// Warm the relative-date phrases. XF's date rendering
// (getRelativeDateTimeInternal / getShortDate) reads these via
// getPhraseCacheRaw(), which does NOT lazy-load — so in this isolated
// dispatch context the post dates would render as raw keys
// ("later_today_at_x"). requirePhrases() queues them; the following
// getPhraseText() flushes the queue into the language cache.
$language->requirePhrases([
'today_at_x', 'yesterday_at_x', 'tomorrow_at_x', 'later_today_at_x',
'day_at_x', 'date_x_at_time_y',
'short_date_x_days', 'short_date_x_hours', 'short_date_x_minutes',
]);
$language->getPhraseText('later_today_at_x');
$templater->addDefaultParam('xf', $app->getGlobalTemplateData());
$templater->addDefaultParam('visitor', \XF::visitor());
$templater->addDefaultParam('style', $style);
// Route-relative thread path (threads/slug.3/[page-N]).
// MUST be the canonical link (boardUrl-based): the plain link builds
// against the REQUEST base, and when WordPress lives in a subdirectory
// with XF at the root (xendevelopment.com layout) that request base is
// /articles — yielding "articles/threads/..." which matches no route
// (Error reply -> null -> legacy-mimic fallback). The board-path strip
// below handles the canonical form in every layout: with XF at root it
// strips nothing; with XF at /community it strips "community/".
$link = $app->router('public')->buildLink('canonical:threads', $thread, $page > 1 ? ['page' => $page] : []);
$routePath = ltrim((string) parse_url($link, PHP_URL_PATH), '/');
$boardSeg = trim((string) (parse_url((string) $app->options()->boardUrl, PHP_URL_PATH) ?: ''), '/'); // community
if ($boardSeg !== '' && strpos($routePath, $boardSeg . '/') === 0)
{
$routePath = substr($routePath, strlen($boardSeg) + 1);
}
// Snapshot the templater's asset state BEFORE the render, so we can
// diff out exactly what the post area (chiefly the editor) requires.
$cssBefore = $templater->getIncludedCss();
$jsBefore = $templater->getIncludedJs();
$inlineBefore = count($templater->getInlineJs());
// Dispatch the stock thread controller via an in-process sub-request.
//
// We deliberately do NOT try to make the sub-request URL match the
// thread's canonical link. That link is built by the app router, whose
// base is environment-dependent (root on a dev WP-integration request;
// an absolute https://…/community/… URL on production behind a proxy/CDN),
// so matching it is fragile and was the source of repeated
// canonical-redirect fallbacks to the legacy comments.
//
// Instead we set the dispatched controller's RESPONSE TYPE to a
// non-'html' value (see $match->setResponseType below). XF\Mvc\Controller
// ::assertCanonicalUrl() returns early when responseType != 'html', so the
// canonical comparison — and its redirect — never runs, on ANY host. CSRF
// is also skipped (checkCsrfIfNeeded only enforces it for non-GET requests
// or 'js' types). The thread_view template is still rendered through the
// html renderer below, so the output is identical to a normal view.
$server = $_SERVER;
$server['SCRIPT_NAME'] = '/index.php';
$server['PHP_SELF'] = '/index.php';
$server['REQUEST_URI'] = '/' . $routePath;
$server['QUERY_STRING'] = '';
$server['REQUEST_METHOD'] = 'GET';
if (empty($server['REMOTE_ADDR']))
{
$server['REMOTE_ADDR'] = '127.0.0.1';
}
$subRequest = new \XF\Http\Request($app->inputFilterer(), [], [], $_COOKIE, $server);
// dispatchFromMatch() (not dispatchLoop) lets a real exception surface.
$dispatcher = new \XF\Mvc\Dispatcher($app, $subRequest);
$match = $dispatcher->route($routePath);
// Force a non-'html' response type so the controller skips
// assertCanonicalUrl() (and the CSRF check). 'embed' is arbitrary; it
// must only be != 'html' and not start with 'js'. We render the
// thread_view via the html renderer regardless (see renderView below).
$match->setResponseType('embed');
$controller = null;
$reply = $dispatcher->dispatchFromMatch($match, $controller);
if (!($reply instanceof \XF\Mvc\Reply\View))
{
$rc = get_class($reply);
if ($reply instanceof \XF\Mvc\Reply\Error)
{
$errs = array_map(function ($e)
{
return is_object($e) && method_exists($e, 'render') ? $e->render() : (string) $e;
}, $reply->getErrors());
$rc .= ': ' . implode(' | ', $errs);
}
elseif (method_exists($reply, 'getUrl'))
{
$rc .= ' url=' . $reply->getUrl();
}
\XF::logError('[PostArea] thread ' . $threadId . ' route=' . $routePath . ' non-view reply=' . $rc);
return null;
}
// Render ONLY the view content (thread_view) — not render().
$renderer = $app->renderer('html');
$renderer->setReply($reply);
$content = $dispatcher->renderView($renderer, $reply);
if ($perPage > 0 && $origPerPage !== null)
{
$opts->messagesPerPage = $origPerPage;
}
if (!is_string($content) || $content === '')
{
\XF::logError('[PostArea] thread ' . $threadId . ' rendered EMPTY content'
. ' (reply=' . get_class($reply) . ', visitor_user_id=' . \XF::visitor()->user_id . ').');
return null;
}
$boardUrl = rtrim((string) $app->options()->boardUrl, '/');
// --- CSS the render added → one board-absolute css.php URL ---------
$newCss = array_values(array_diff($templater->getIncludedCss(), $cssBefore));
$cssUrl = null;
if ($newCss)
{
$cssUrl = $this->absoluteAssetUrl($templater->getCssLoadUrl($newCss), $boardUrl, 'css.php');
}
// --- JS the render added → board-absolute file URLs ---------------
$newJs = array_diff_key($templater->getIncludedJs(), $jsBefore);
$jsUrls = [];
foreach (array_keys($newJs) as $url)
{
$jsUrls[] = $this->absoluteAssetUrl($url, $boardUrl, '/js/');
}
$inlineJs = array_slice($templater->getInlineJs(), $inlineBefore);
return [
'html' => $content,
'cssUrl' => $cssUrl,
'jsUrls' => $jsUrls,
'inlineJs' => $inlineJs,
// The thread starter is the bridge's auto-post that just links
// back to the WP article — redundant on the article page. Report
// its id so the consumer can hide it (the reader is already here).
'firstPostId' => (int) $thread->first_post_id,
];
}
catch (\Throwable $e)
{
\XF::logException($e, false, '[PostArea] render thread ' . $threadId . ': ');
return null;
}
}