New:Socket for Asana Is Now Available.Learn more
Get Started

@speed.press/kit

Package Overview
Dependencies
Maintainers
1
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@speed.press/kit - npm Package Compare versions

Comparing version
1.9.0
to
1.10.0
+91
src/utils/mirror/pagination.js
// Paged-archive support (#32): a static clone can't answer the AJAX "Load
// More" page builders wire to admin-ajax.php (no backend, CORS, expired
// nonce) — only page 1 of any listing survives. The mirror therefore captures
// the server-rendered /page/N/ archives too and rewrites AJAX load-more
// buttons into plain links onto them, so every post stays reachable.
// A path's paged variant: /blog/ → /blog/page/2/, / → /page/2/. Extension-style
// permalinks don't paginate.
export function pagedPath(basePath, n) {
if (!basePath.endsWith('/')) return null;
return `${basePath}page/${n}/`;
}
/** Is this path itself a /page/N/ archive page? Returns { base, n } or null. */
export function parsePagedPath(path) {
const m = String(path || '').match(/^(.*\/)page\/(\d+)\/$/);
return m ? { base: m[1], n: parseInt(m[2], 10) } : null;
}
/**
* Same-origin /page/N/ URLs referenced by a page (numbered pagination themes
* emit real links — those name exactly the pages worth capturing, including
* the max N visible in the pager).
*/
export function findPagedUrls(html, pageUrl, origin) {
const found = new Set();
for (const m of String(html || '').matchAll(/<a\b[^>]*?\bhref=(["'])([^"']+)\1/gi)) {
let abs;
try { abs = new URL(m[2], pageUrl); } catch { continue; }
if (abs.origin !== origin) continue;
if (/\/page\/\d+\/?$/.test(abs.pathname)) {
const p = abs.pathname.endsWith('/') ? abs.pathname : abs.pathname + '/';
found.add(origin + p);
}
}
return [...found];
}
/**
* Does the page paginate through an AJAX load-more (admin-ajax.php + a
* load-more config/button)? Essential Blocks Post Grid, infinite scroll and
* most builder listings match; a page with only a WP comment/search reference
* to admin-ajax does not.
*/
export function hasAjaxLoadMore(html) {
const s = String(html || '');
return /admin-ajax\.php/i.test(s) && /load[_-]?\s*more|infinite[_-]?scroll|loadMoreType|enableMorePosts/i.test(s);
}
/**
* Rewrite the page's AJAX load-more control into a plain link to the captured
* next page. The original element's classes are preserved so the link renders
* with the button's existing styling. Returns { html, rewritten }.
*/
export function rewriteLoadMoreToLink(html, nextPath, { label = '' } = {}) {
let rewritten = false;
const out = String(html || '').replace(
/<(button|a|div|span)\b([^>]*(?:class|id)=["'][^"']*load[_-]?more[^"']*["'][^>]*)>([\s\S]*?)<\/\1>/gi,
(m, tag, attrs, inner) => {
if (rewritten) return m; // one control per page — nested wrappers stay
// Wrapper elements that CONTAIN the button match too (greedy inner) —
// only rewrite the innermost match by recursing into the inner HTML first.
if (/<(?:button|a|div|span)\b[^>]*(?:class|id)=["'][^"']*load[_-]?more/i.test(inner)) {
const sub = rewriteLoadMoreToLink(inner, nextPath, { label });
if (sub.rewritten) { rewritten = true; return `<${tag}${attrs}>${sub.html}</${tag}>`; }
return m;
}
rewritten = true;
const cls = (attrs.match(/class=(["'])([^"']*)\1/i) || [])[2] || '';
const text = label || inner.replace(/<[^>]+>/g, '').trim() || 'Load More';
return `<a href="${nextPath}"${cls ? ` class="${cls}"` : ''} data-xch-paged-nav>${text}</a>`;
}
);
return { html: rewritten ? out : String(html || ''), rewritten };
}
/**
* rel=next/prev links for a paged sequence — head hints so crawlers walk the
* archive chain. Injected only when absent.
*/
export function buildPagedRelLinks(path, { hasNext, base = null, n = 1 } = {}) {
const tags = [];
if (hasNext) {
const next = pagedPath(base || path, n + 1);
if (next) tags.push(`<link rel="next" href="${next}" data-xch-paged>`);
}
if (base && n >= 2) {
tags.push(`<link rel="prev" href="${n === 2 ? base : pagedPath(base, n - 1)}" data-xch-paged>`);
}
return tags.join('\n');
}
// Localization for "mirror mode" recaptures: a freshly fetched WordPress page
// references the source origin everywhere (assets, links, JSON configs). This
// maps those references back onto the clone — assets to the /_mirror/ files the
// original capture localized, page links to root-relative paths the clone
// serves — without any network access of its own. URLs the clone can't back
// (an image uploaded after the mirror, an uncaptured page) stay absolute so
// they keep working against the source.
import { createHash } from 'crypto';
import { existsSync } from 'fs';
import { resolve } from 'path';
const ASSET_RE = /\.(?:css|js|mjs|png|jpe?g|gif|svg|webp|avif|ico|bmp|woff2?|ttf|otf|eot)(?:[?#]|$)/i;
// Same WordPress runtime/admin scripts the capture strips — dead weight (and
// non-functional) in a static clone; a recapture must not reintroduce them.
const WP_RUNTIME_SCRIPT_RE = /(?:admin-ajax\.php|wp-emoji-release(?:\.min)?\.js|wp-embed(?:\.min)?\.js|jquery[.-]migrate(?:\.min)?\.js|comment-reply(?:\.min)?\.js|\/wp-includes\/js\/(?:heartbeat|comment-reply|wp-embed|wp-emoji))/i;
const md5 = (s) => createHash('md5').update(s).digest('hex').slice(0, 8);
/**
* The /_mirror/ filename the capture-time AssetLocalizer stored a URL under.
* Its scheme is deterministic — md5(url) prefix + sanitized basename — so the
* regenerator can re-derive it with no cache file (.mirror-asset-cache.json is
* gitignored and absent in deployed containers). The stored extension may have
* been forced from the Content-Type for extensionless URLs, so a small set of
* candidates is returned; callers pick whichever exists on disk.
*/
export function mirrorAssetNames(absUrl) {
const noQ = absUrl.split('#')[0].split('?')[0];
const base = (noQ.split('/').pop() || 'asset').replace(/[^a-zA-Z0-9._-]/g, '-').slice(-60);
const prefix = md5(absUrl) + '-';
const names = [prefix + base];
if (!/\.[a-z0-9]{1,5}$/i.test(base)) {
for (const ext of ['.css', '.js', '.woff2', '.woff']) names.push(prefix + base + ext);
}
return names;
}
function decodeBasicEntities(s) {
return s.replace(/&amp;/g, '&').replace(/&#0?38;/g, '&');
}
/**
* Rewrite every reference to the WordPress source origin(s) in a recaptured
* page so it resolves on the clone. Handles attributes, srcset chunks, inline
* and embedded CSS url(), and JSON-escaped URLs in script configs (page
* builders embed `https:\/\/host\/...`) via a single origin-URL sweep.
*
* - asset URLs → the existing /_mirror/ file when it's on disk, else left
* - page URLs → root-relative when the clone serves the path, else left
* - canonical / og:url / twitter:url → re-rooted on the deployed origin
*
* @param {string} html recaptured page HTML
* @param {object} opts
* @param {string[]} opts.origins WordPress origins to localize away
* @param {string} opts.htmlDir served root (/_mirror/ + pages live here)
* @param {string} [opts.deployedOrigin] public origin after cutover ('' = same host)
*/
export function localizeRecapturedHtml(html, { origins, htmlDir, deployedOrigin = '' }) {
let out = String(html || '');
const srcOrigins = [...new Set((origins || []).filter(Boolean).map((o) => o.replace(/\/+$/, '')))];
if (!srcOrigins.length) return out;
out = out.replace(/<base\b[^>]*>/gi, '');
out = out.replace(/<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*>\s*<\/script>/gi,
(m, src) => (WP_RUNTIME_SCRIPT_RE.test(src) ? '' : m));
out = out.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi,
(m, body) => (/_wpemojiSettings|wp-?emoji/i.test(body) ? '' : m));
const servesPath = (path) => {
const rel = path.endsWith('/') ? path + 'index.html' : path;
const file = resolve(htmlDir, '.' + rel);
return existsSync(file) || existsSync(resolve(htmlDir, '.' + path, 'index.html')) || existsSync(resolve(htmlDir, '.' + path + '.html'));
};
const assetOnDisk = (absUrl) => {
for (const name of mirrorAssetNames(absUrl)) {
if (existsSync(resolve(htmlDir, '_mirror', name))) return '/_mirror/' + name;
}
return null;
};
// One sweep per origin: plain, protocol-relative, and JSON-escaped forms.
for (const origin of srcOrigins) {
let host;
try { host = new URL(origin).host; } catch { continue; }
const hostEsc = host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// scheme, JSON-escaped scheme, or protocol-relative; path may be
// JSON-escaped too. Terminators cover quotes, whitespace, ), <, and the
// srcset chunk separator.
const urlRe = new RegExp(`(?:https?:(?:\\\\?/){2}|(?:\\\\?/){2})${hostEsc}(?:\\\\?/[^"'\\s)<,\\\\]*(?:\\\\/[^"'\\s)<,\\\\]*)*)?`, 'g');
out = out.replace(urlRe, (match) => {
const escaped = match.includes('\\/');
const plain = decodeBasicEntities(match.replace(/\\\//g, '/'));
let abs;
try { abs = new URL(plain.startsWith('//') ? 'https:' + plain : plain); } catch { return match; }
const reEscape = (s) => (escaped ? s.replace(/\//g, '\\/') : s);
if (ASSET_RE.test(abs.pathname)) {
const local = assetOnDisk(abs.href) || assetOnDisk(origin + abs.pathname + abs.search);
return local ? reEscape(local) : match;
}
// Page link: root-relative when the clone serves it (query/hash survive).
let p = abs.pathname || '/';
if (!p.endsWith('/') && !/\.[a-z0-9]+$/i.test(p)) p += '/';
if (servesPath(p)) return reEscape(p + abs.search + abs.hash);
return match;
});
}
// Head identity: the recaptured canonical/og:url/twitter:url still assert the
// WordPress origin. After the sweep above they're root-relative when served —
// re-root them absolute on the deployed origin (canonical must be absolute).
if (deployedOrigin) {
const dep = deployedOrigin.replace(/\/+$/, '');
const reRoot = (u) => {
if (u.startsWith('/') && !u.startsWith('//')) return dep + u;
// Still absolute on a source origin (e.g. the page's own path wasn't on
// disk yet at sweep time) — the head identity must move to the deployed
// origin regardless.
for (const origin of srcOrigins) {
if (u === origin || u.startsWith(origin + '/')) {
try { const p = new URL(u); return dep + p.pathname + p.search + p.hash; } catch { return u; }
}
}
return u;
};
out = out
.replace(/(<link\b[^>]*rel=["']canonical["'][^>]*href=["'])([^"']+)(["'])/i, (m, o, u, c) => o + reRoot(u) + c)
.replace(/<meta\b[^>]*>/gi, (tag) => {
if (!/(?:property|name)=["'](?:og:url|twitter:url)["']/i.test(tag)) return tag;
return tag.replace(/(content=["'])([^"']+)(["'])/i, (m, o, u, c) => o + reRoot(u) + c);
});
}
return out;
}
/**
* Carry the kit's injected head artifacts from the previously served page into
* a recaptured one: the markdown-twin/llms alternate links (data-xch-ai), the
* Speculation Rules block, kit-injected JSON-LD, and the /feed.xml alternate.
* Without this every mirror-mode update would strip the clone's
* AI-discoverability and instant-nav layer until the next full mirror.
*/
export function portKitHeadTags(oldHtml, newHtml) {
let out = newHtml;
const carry = [];
if (!/data-xch-ai/.test(out)) {
for (const m of String(oldHtml || '').match(/<link\b[^>]*data-xch-ai[^>]*>/gi) || []) carry.push(m);
const ld = String(oldHtml || '').match(/<script type="application\/ld\+json" data-xch-ai>[\s\S]*?<\/script>/);
// Only when the recapture ships no structured data of its own (an SEO
// plugin's graph must never be doubled up).
if (ld && !/application\/ld\+json/i.test(out)) carry.push(ld[0]);
}
if (!/id="xch-speculation"/.test(out)) {
const rules = String(oldHtml || '').match(/<script type="speculationrules" id="xch-speculation">[\s\S]*?<\/script>/);
if (rules) carry.push(rules[0]);
}
if (carry.length) {
const block = '\n' + carry.join('\n') + '\n';
out = /<\/head>/i.test(out) ? out.replace(/<\/head>/i, block + '</head>') : block + out;
}
// Feed links: the old page advertised the clone's own /feed.xml — keep the
// recapture pointing there instead of the WordPress origin feed.
if (/href=["']\/feed\.xml["']/.test(String(oldHtml || ''))) {
out = out.replace(/<link\b[^>]*type=["']application\/(?:rss|atom)\+xml["'][^>]*>/gi, (tag) => {
const href = (tag.match(/href=(["'])([^"']+)\1/i) || [])[2] || '';
if (/comments/i.test(href)) return '';
return tag.replace(/href=(["'])[^"']+\1/i, 'href="/feed.xml"');
});
}
return out;
}
#!/usr/bin/env node
// Incremental catch-up sync: pull everything edited/created in WordPress since
// the last run and apply it through the SAME surgical-update path the webhook
// uses (page HTML or mirror recapture, markdown twin, search corpora,
// llms.txt, sitemap, feed, api-catalog). For when webhooks were down, the
// bridge plugin wasn't connected yet, or a bulk edit landed upstream.
//
// node regenerator/sync-new.mjs # since the stored watermark
// node regenerator/sync-new.mjs --since 2026-07-01T00:00:00Z
// node regenerator/sync-new.mjs --dry-run # list, change nothing
//
// The watermark persists in regenerator/.sync-state.json. Deletions don't
// surface through modified_after — those still need webhooks (or a re-mirror).
// Honors WP_API_URL, WP_FRONT_URL, WP_FETCH_UA, WP_FETCH_HEADERS,
// REGEN_CONTENT_MODE, HTML_DIR — same environment as server.js.
import { readFileSync, writeFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { surgicalUpdate, fetchWp } from './server.js';
const __dir = dirname(fileURLToPath(import.meta.url));
const STATE_PATH = resolve(__dir, '.sync-state.json');
const WP_API_URL = (process.env.WP_API_URL || '').replace(/\/+$/, '');
// Registered types that never map to served pages.
const SKIP_TYPES = new Set([
'attachment', 'wp_block', 'wp_template', 'wp_template_part', 'wp_navigation',
'nav_menu_item', 'wp_font_family', 'wp_font_face', 'wp_global_styles', 'revision',
]);
function argValue(name) {
const i = process.argv.indexOf(name);
return i !== -1 ? process.argv[i + 1] : undefined;
}
async function main() {
if (!WP_API_URL) {
console.error('sync-new: WP_API_URL is not set');
process.exit(1);
}
const dryRun = process.argv.includes('--dry-run');
let state = {};
try { state = JSON.parse(readFileSync(STATE_PATH, 'utf8')) || {}; } catch { /* first run */ }
const since = argValue('--since') || state.last_sync ||
new Date(Date.now() - 24 * 3600 * 1000).toISOString(); // first run: last 24h
const sinceIso = new Date(since).toISOString();
if (Number.isNaN(Date.parse(since))) {
console.error(`sync-new: invalid --since date "${since}"`);
process.exit(1);
}
const runStartedAt = new Date().toISOString();
console.log(`sync-new: syncing changes modified after ${sinceIso}${dryRun ? ' (dry run)' : ''}`);
const typesRes = await fetchWp(`${WP_API_URL}/wp/v2/types`);
if (!typesRes.ok) {
console.error(`sync-new: /wp/v2/types → HTTP ${typesRes.status}`);
process.exit(1);
}
const types = Object.values(await typesRes.json() || {})
.filter((t) => t && t.slug && t.rest_base && !SKIP_TYPES.has(t.slug) && t.viewable !== false);
let synced = 0, failed = 0;
for (const t of types) {
let page = 1;
for (;;) {
const params = new URLSearchParams({
modified_after: sinceIso, per_page: '100', page: String(page),
orderby: 'modified', order: 'asc', _fields: 'id,slug,link,type,modified',
});
let items;
try {
const r = await fetchWp(`${WP_API_URL}/wp/v2/${t.rest_base}?${params}`);
if (!r.ok) break; // past the end (400) or type doesn't support modified_after
items = await r.json();
} catch (err) {
console.error(`sync-new: ${t.rest_base} page ${page} fetch failed: ${err.message}`);
break;
}
if (!Array.isArray(items) || items.length === 0) break;
for (const item of items) {
const label = `${item.type || t.slug}:${item.id} ${item.link || item.slug}`;
if (dryRun) { console.log(` would sync ${label}`); synced += 1; continue; }
try {
await surgicalUpdate({
event: 'post.updated',
category: 'surgical',
payload: {
id: item.id, post_type: item.type || t.slug, slug: item.slug,
link: item.link, rest_base: t.rest_base,
},
});
synced += 1;
console.log(` ✓ ${label}`);
} catch (err) {
failed += 1;
console.error(` ✗ ${label}: ${err.message}`);
}
}
if (items.length < 100) break;
page += 1;
}
}
if (!dryRun) {
writeFileSync(STATE_PATH, JSON.stringify({ last_sync: runStartedAt }, null, 2) + '\n');
}
console.log(`sync-new: ${synced} item(s) ${dryRun ? 'pending' : 'synced'}, ${failed} failed` +
(dryRun ? '' : ` — watermark → ${runStartedAt}`));
if (failed) process.exitCode = 1;
}
main().catch((err) => {
console.error('sync-new: fatal:', err.message);
process.exit(1);
});
+5
-5
{
"name": "@speed.press/kit",
"version": "1.9.0",
"version": "1.10.0",
"description": "Convert WordPress sites into pixel-faithful Astro frontends",

@@ -41,11 +41,11 @@ "type": "module",

"@anthropic-ai/sdk": "^0.26.0",
"chalk": "^4.1.2",
"commander": "^11.0.0",
"fs-extra": "^11.2.0",
"node-fetch": "^3.3.0",
"node-html-parser": "^6.1.0",
"ora": "^5.4.1",
"chalk": "^4.1.2",
"yaml": "^2.4.0",
"pixelmatch": "^5.3.0",
"pngjs": "^6.0.0",
"node-fetch": "^3.3.0",
"fs-extra": "^11.2.0"
"yaml": "^2.4.0"
},

@@ -52,0 +52,0 @@ "devDependencies": {

@@ -152,2 +152,5 @@ <?php

. 'placeholder="https://your-astro-site.com/api/regenerate" />';
echo ' <button type="button" class="button button-secondary xcloud-copy" data-copy-target="xcloud_headless_webhook_url">'
. esc_html__( 'Copy', 'xcloud-headless-bridge' )
. '</button>';
echo '<p class="description">' . esc_html__( 'The webhook endpoint on your Astro frontend that receives content change events — for Speed.Press / xCloud Headless sites this is https://<your-domain>/api/regenerate.', 'xcloud-headless-bridge' ) . '</p>';

@@ -160,6 +163,15 @@ }

. 'value="' . esc_attr( $value ) . '" class="regular-text" autocomplete="new-password" />';
echo ' <button type="button" id="xcloud-toggle-secret" class="button button-secondary" aria-pressed="false">'
. esc_html__( 'Show', 'xcloud-headless-bridge' )
. '</button>';
// Copies the EXACT stored value — hand-copying from a revealed password
// field invites stray whitespace / partial selections, the top cause of
// 401 "Invalid signature" during container setup.
echo ' <button type="button" class="button button-secondary xcloud-copy" data-copy-target="xcloud_headless_secret">'
. esc_html__( 'Copy', 'xcloud-headless-bridge' )
. '</button>';
echo ' <button type="button" id="xcloud-generate-secret" class="button button-secondary">'
. esc_html__( 'Generate', 'xcloud-headless-bridge' )
. '</button>';
echo '<p class="description">' . esc_html__( 'Used to sign webhook payloads with HMAC-SHA256. Keep this secret and set the same value on your Astro frontend.', 'xcloud-headless-bridge' ) . '</p>';
echo '<p class="description">' . esc_html__( 'Used to sign webhook payloads with HMAC-SHA256. Keep this secret and set the same value on your Astro frontend (WEBHOOK_SECRET).', 'xcloud-headless-bridge' ) . '</p>';
}

@@ -214,2 +226,4 @@

$last_webhook = get_option( 'xcloud_headless_last_webhook', null );
$webhook_log = get_option( 'xcloud_headless_webhook_log', [] );
$webhook_log = is_array( $webhook_log ) ? $webhook_log : [];
$rest_nonce = wp_create_nonce( 'wp_rest' );

@@ -325,2 +339,56 @@ $ajax_nonce = wp_create_nonce( self::NONCE_ACTION );

</div><!-- .xcloud-grid -->
<!-- Webhook delivery log -->
<div class="xcloud-card" style="margin-top:16px">
<h2><?php esc_html_e( 'Webhook Delivery Log', 'xcloud-headless-bridge' ); ?></h2>
<?php if ( ! empty( $webhook_log ) ) : ?>
<table class="widefat striped xcloud-log-table">
<thead>
<tr>
<th><?php esc_html_e( 'Time', 'xcloud-headless-bridge' ); ?></th>
<th><?php esc_html_e( 'Event', 'xcloud-headless-bridge' ); ?></th>
<th><?php esc_html_e( 'Category', 'xcloud-headless-bridge' ); ?></th>
<th><?php esc_html_e( 'Target', 'xcloud-headless-bridge' ); ?></th>
<th><?php esc_html_e( 'Status', 'xcloud-headless-bridge' ); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ( $webhook_log as $entry ) : ?>
<tr>
<td>
<?php
echo ! empty( $entry['timestamp'] )
? esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $entry['timestamp'] ) )
: '—';
?>
</td>
<td><code><?php echo esc_html( $entry['event'] ?? '—' ); ?></code></td>
<td><?php echo esc_html( $entry['category'] ?? '—' ); ?></td>
<td class="xcloud-log-url"><code><?php echo esc_html( $entry['url'] ?? '—' ); ?></code></td>
<td>
<?php
$code = (int) ( $entry['response_code'] ?? 0 );
$success = ! empty( $entry['success'] );
$cls = $success ? 'xcloud-badge--ok' : 'xcloud-badge--error';
$label = $code > 0 ? (string) $code : __( 'error', 'xcloud-headless-bridge' );
echo '<span class="xcloud-badge ' . esc_attr( $cls ) . '">' . esc_html( $label ) . '</span>';
if ( ! $success && ! empty( $entry['error'] ) ) {
echo ' <span class="description">' . esc_html( $entry['error'] ) . '</span>';
}
?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p class="description">
<?php
/* translators: %d: number of retained log entries. */
echo esc_html( sprintf( __( 'Last %d delivery attempts (newest first). Verifies that create/edit/delete actions actually fired without needing container or server logs.', 'xcloud-headless-bridge' ), count( $webhook_log ) ) );
?>
</p>
<?php else : ?>
<p class="description"><?php esc_html_e( 'No deliveries recorded yet — save a post (or send a test webhook) and the attempt will appear here.', 'xcloud-headless-bridge' ); ?></p>
<?php endif; ?>
</div>
</div><!-- .wrap -->

@@ -340,2 +408,37 @@

// ── Show / hide secret ───────────────────────────────────────────
$('#xcloud-toggle-secret').on('click', function(){
var $input = $('#xcloud_headless_secret');
var reveal = $input.attr('type') === 'password';
$input.attr('type', reveal ? 'text' : 'password');
$(this).attr('aria-pressed', reveal ? 'true' : 'false').text(
reveal ? <?php echo wp_json_encode( __( 'Hide', 'xcloud-headless-bridge' ) ); ?>
: <?php echo wp_json_encode( __( 'Show', 'xcloud-headless-bridge' ) ); ?>
);
});
// ── Copy to clipboard (exact value — no selection, no stray spaces) ──
$('.xcloud-copy').on('click', function(){
var $btn = $(this);
var value = ($('#' + $btn.data('copy-target')).val() || '');
var done = function(){
var original = $btn.text();
$btn.text(<?php echo wp_json_encode( __( 'Copied!', 'xcloud-headless-bridge' ) ); ?>);
setTimeout(function(){ $btn.text(original); }, 1500);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(value).then(done);
} else {
// http:// admin — clipboard API is unavailable, fall back.
var ta = document.createElement('textarea');
ta.value = value;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); done(); } catch(e) {}
document.body.removeChild(ta);
}
});
// ── Connection status check ──────────────────────────────────────

@@ -342,0 +445,0 @@ function checkHealth(){

@@ -31,2 +31,7 @@ <?php

/**
* Delivery-log ring buffer size (newest first, shown on the settings page).
*/
private const WEBHOOK_LOG_MAX = 20;
public function __construct() {

@@ -257,2 +262,3 @@ add_action( 'save_post', [ $this, 'on_save_post' ], 10, 3 );

) );
$this->log_delivery( $timestamp, $event, $category, $webhook_url, 0, false, $response->get_error_message() );
return false;

@@ -275,2 +281,3 @@ }

);
$this->log_delivery( $timestamp, $event, $category, $webhook_url, $response_code, $success );

@@ -299,5 +306,10 @@ if ( ! $success ) {

public function get_post_payload( WP_Post $post ): array {
$type_object = get_post_type_object( $post->post_type );
return [
'id' => $post->ID,
'post_type' => $post->post_type,
// The type's REST base — the regenerator fetches /wp/v2/{rest_base},
// which for CPTs (docs, changelogs, …) differs from the type slug.
'rest_base' => ( $type_object && ! empty( $type_object->rest_base ) ) ? $type_object->rest_base : $post->post_type,
'slug' => $post->post_name,

@@ -310,2 +322,36 @@ 'status' => $post->post_status,

}
/**
* Append one delivery attempt to the capped webhook log (newest first) so
* the settings page can show delivery history without server log access.
*
* @param int $timestamp Unix time of the attempt.
* @param string $event Event identifier.
* @param string $category 'surgical' | 'full'.
* @param string $url Target webhook URL.
* @param int $response_code HTTP status (0 = transport error).
* @param bool $success 2xx response.
* @param string $error Transport error message, if any.
*/
private function log_delivery( int $timestamp, string $event, string $category, string $url, int $response_code, bool $success, string $error = '' ): void {
$log = get_option( 'xcloud_headless_webhook_log', [] );
if ( ! is_array( $log ) ) {
$log = [];
}
array_unshift(
$log,
[
'timestamp' => $timestamp,
'event' => $event,
'category' => $category,
'url' => $url,
'response_code' => $response_code,
'success' => $success,
'error' => $error,
]
);
update_option( 'xcloud_headless_webhook_log', array_slice( $log, 0, self::WEBHOOK_LOG_MAX ), false );
}
}

@@ -21,2 +21,3 @@ import ora from 'ora';

import { collectCssLinks, writeHeadlessRoutes } from '../utils/mirror/headless-routes.js';
import { pagedPath, parsePagedPath, findPagedUrls, hasAjaxLoadMore, rewriteLoadMoreToLink, buildPagedRelLinks } from '../utils/mirror/pagination.js';

@@ -258,2 +259,3 @@ const __dir = dirname(fileURLToPath(import.meta.url));

let done = 0;
let captureTotal = captureUrls.length; // grows when paged archives are found
// path -> outFile for routes whose re-render/localize failed this run. Their

@@ -263,3 +265,3 @@ // PREVIOUS capture may still be on disk and served — those routes must not

const captureFailed = new Map();
const pages = await pool(captureUrls, concurrency, async (url) => {
const capturePage = async (url) => {
const path = urlToPath(url);

@@ -274,3 +276,3 @@ const outFile = urlToOutFile(url);

const [ctype, cid] = (prev.contentKey || '').split(':');
console.log(` ${chalk.cyan('↻')} [${++done}/${captureUrls.length}] ${url}${chalk.dim(' · reused')}`);
console.log(` ${chalk.cyan('↻')} [${++done}/${captureTotal}] ${url}${chalk.dim(' · reused')}`);
return {

@@ -301,11 +303,141 @@ url, path, outFile, reused: true, modified: meta.modified,

}
console.log(` ${chalk.green('✓')} [${++done}/${captureUrls.length}] ${url}${rec.contentKey ? chalk.dim(' · surgical') : ''}`);
console.log(` ${chalk.green('✓')} [${++done}/${captureTotal}] ${url}${rec.contentKey ? chalk.dim(' · surgical') : ''}`);
return rec;
} catch (err) {
console.log(` ${chalk.red('✗')} [${++done}/${captureUrls.length}] ${url} — ${err.message}`);
console.log(` ${chalk.red('✗')} [${++done}/${captureTotal}] ${url} — ${err.message}`);
captureFailed.set(path, outFile);
return null;
}
});
};
const pages = await pool(captureUrls, concurrency, capturePage);
// Phase 1.2 — paged archives (#32): AJAX "Load More" can't work on a static
// clone (no admin-ajax.php backend, cross-origin, expired nonce), so only
// page 1 of any listing would survive. Capture the server-rendered /page/N/
// chain too: numbered pagers link their pages outright (waves follow the
// sliding window); load-more-only archives are probed sequentially until the
// origin runs out. Captured paged pages join `pages` — served + localized
// like any other — but are flagged so sitemap/llms/search skip them
// (WordPress excludes paged archives from those inventories too).
{
const probeOk = async (u) => {
try {
const want = new URL(u).pathname;
const r = await fetch(u, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; xcloud-headless-mirror/1.0)' },
redirect: 'follow', signal: AbortSignal.timeout(15000),
});
if (!r.ok) return false;
// Out-of-range /page/N/ redirects back to the archive — that's a miss.
const got = new URL(r.url || u).pathname;
return (got.endsWith('/') ? got : got + '/') === want;
} catch { return false; }
};
const linkedMax = new Map(); // archive base path → deepest /page/N/ seen
const loadMoreBases = new Set();
const noteHtml = (html, url, path) => {
for (const u of findPagedUrls(html, url, origin)) {
const pp = parsePagedPath(urlToPath(u));
if (pp) linkedMax.set(pp.base, Math.max(linkedMax.get(pp.base) || 0, pp.n));
}
if (hasAjaxLoadMore(html) && path.endsWith('/') && !parsePagedPath(path)) loadMoreBases.add(path);
};
for (const p of pages) if (p && !p.reused && p.html) noteHtml(p.html, p.url, p.path);
// Load-more archives expose no pagination links — walk /page/2/, /page/3/…
// against the origin until it stops answering.
for (const base of loadMoreBases) {
let n = (linkedMax.get(base) || 1) + 1;
while (pages.length + (linkedMax.get(base) || 0) < maxPages && n < 1000) {
if (!(await probeOk(origin + pagedPath(base, n)))) break;
linkedMax.set(base, n);
n += 1;
}
}
const queued = new Set();
const pagedCaptured = [];
for (let wave = 0; wave < 20; wave++) {
const targets = [];
for (const [base, maxN] of linkedMax) {
for (let n = 2; n <= maxN; n++) {
const u = origin + pagedPath(base, n);
const key = normalizeUrl(u);
if (!key || knownMap.has(key) || queued.has(key)) continue;
queued.add(key);
targets.push(u);
}
}
const budget = Math.max(0, maxPages - pages.length);
const batch = targets.slice(0, budget);
if (targets.length > batch.length) {
console.log(chalk.yellow(` ⚠ ${targets.length - batch.length} paged archive page(s) skipped by --max-pages ${maxPages}`));
}
if (!batch.length) break;
for (const u of batch) knownMap.set(normalizeUrl(u), urlToPath(u));
captureTotal += batch.length;
const wavePages = await pool(batch, concurrency, capturePage);
for (const p of wavePages) {
if (!p) continue;
p.paged = true;
pagedCaptured.push(p);
pages.push(p);
if (p.html) noteHtml(p.html, p.url, p.path);
}
if (!wavePages.some(Boolean)) break;
}
// Rewrite each archive's AJAX load-more into a plain link to its captured
// next page (keeping the button's classes → styling) and add rel=next/prev
// head hints so crawlers walk the chain.
const servedPaged = new Set(pages.filter((p) => p && p.paged).map((p) => p.path));
if (prevManifest) for (const e of prevManifest.page_index || []) {
if (e && parsePagedPath(e.path)) servedPaged.add(e.path);
}
for (const p of pages) {
if (!p || p.reused || !p.html || !p.path.endsWith('/')) continue;
const pp = parsePagedPath(p.path);
const base = pp ? pp.base : p.path;
const n = pp ? pp.n : 1;
const nextP = pagedPath(base, n + 1);
const hasNext = !!nextP && servedPaged.has(nextP);
if (!pp && !hasNext) continue; // no captured chain from this page
if (hasNext && hasAjaxLoadMore(p.html)) {
p.html = rewriteLoadMoreToLink(p.html, nextP).html;
}
if (!p.html.includes('data-xch-paged')) {
const rel = buildPagedRelLinks(p.path, { hasNext, base: pp ? base : null, n });
if (rel && /<\/head>/i.test(p.html)) p.html = p.html.replace(/<\/head>/i, '\n' + rel + '\n</head>');
}
}
if (pagedCaptured.length) {
console.log(chalk.dim(` paged archives: captured ${pagedCaptured.length} /page/N/ page(s); load-more buttons now link the chain`));
}
}
// Phase 1.3 — themed 404 (#34): capture the source's real 404 page so the
// clone's error page keeps the design instead of the generated placeholder.
// A nonsense path forces WordPress to render its 404 template; the capture
// is localized + relinked like any page and served via nginx error_page.
let themed404 = false;
try {
const probeUrl = origin + '/xch-404-probe-' + Date.now().toString(36) + '/';
const r = await fetch(probeUrl, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; xcloud-headless-mirror/1.0)' },
redirect: 'follow', signal: AbortSignal.timeout(20000),
});
if (r.status === 404) {
const raw = await r.text();
if (/<html[\s>]/i.test(raw)) {
let h = await localizer.rewriteHtml(raw, origin + '/');
h = rewriteLinks(h, origin + '/', knownMap, origin);
await mkdir(publicDir, { recursive: true });
await writeFile(join(publicDir, '404.html'), h, 'utf8');
themed404 = true;
console.log(chalk.dim(' 404: captured the source\'s themed 404 page → public/404.html'));
}
}
} catch { /* origin refused the probe — keep the generated placeholder */ }
if (!themed404 && existsSync(join(publicDir, '404.html'))) themed404 = true; // earlier batch captured it
// Phase 1.5 — optimize the localized images in place (recompress + downscale cap

@@ -557,2 +689,3 @@ // + .webp siblings for nginx Accept-negotiation) and measure dimensions/LQIP for

renderEngine: renderer.engine,
themed404,
});

@@ -674,6 +807,10 @@ let manifest = buildMirrorManifest({ origin, renderEngine: renderer.engine, pages, localizer, imageStats });

// chunks and /sitemap.xml becomes a sitemapindex over them.
// /page/N/ archives are served but excluded from discovery inventories —
// they duplicate their base listing (WordPress leaves them out of sitemaps
// too). The flag covers this run; parsePagedPath covers prior-batch entries.
const inventoryPages = manifest.page_index.filter((e) => e && !e.paged && !parsePagedPath(e.path));
const { files: sitemapFiles, indexTargets } = buildSitemapArtifacts({
siteUrl: artifactOrigin,
pages: [
...manifest.page_index.map((e) => ({ path: e.path, lastmod: e.modified || undefined })),
...inventoryPages.map((e) => ({ path: e.path, lastmod: e.modified || undefined })),
...headlessEntries,

@@ -724,3 +861,3 @@ ],

pages: [
...manifest.page_index.map((e) => ({ path: e.path, title: e.title, description: e.description })),
...inventoryPages.map((e) => ({ path: e.path, title: e.title, description: e.description })),
...headlessEntries,

@@ -736,3 +873,3 @@ ],

const searchEntries = [
...manifest.page_index.map((e) => {
...inventoryPages.map((e) => {
const m = pageMeta.get(e.path) || {};

@@ -784,3 +921,3 @@ return { path: e.path, title: e.title || m.title || '', description: e.description || m.description || '', text: (m.text || '').toLowerCase() };

siteUrl: artifactOrigin,
pages: manifest.page_index.map((e) => ({ path: e.path })),
pages: inventoryPages.map((e) => ({ path: e.path })),
markdownTwins: true,

@@ -787,0 +924,0 @@ feed: existsSync(feedPath),

@@ -33,15 +33,64 @@ // URL ↔ local-path helpers + internal-link rewriting so the mirrored site

// Values that can never be page navigation — leave untouched wherever found.
const NON_NAV_VALUE = /^(?:#|javascript:|mailto:|tel:|sms:|data:|blob:)/i;
/**
* Rewrite same-origin <a href> to local paths when the target was crawled.
* Same-origin links that weren't crawled are left absolute (they still work,
* pointing at the live site); external links are untouched.
* Local path for a link value when its target is a captured page, else null.
* Query and fragment survive the rewrite (`/#pricing` keeps scrolling), the
* page identity itself is matched query/hash-blind via normalizeUrl.
*/
function localTarget(value, pageUrl, knownMap, origin) {
if (!value || NON_NAV_VALUE.test(value)) return null;
let abs;
try { abs = new URL(value, pageUrl); } catch { return null; }
if (!abs.href.startsWith(origin)) return null;
const local = knownMap.get(normalizeUrl(abs.href));
if (!local) return null;
const next = local + (abs.search || '') + (abs.hash || '');
return next === value ? null : next;
}
/**
* Rewrite same-origin navigation URLs to local paths when the target was
* crawled. Covers:
* - <a>/<area> href
* - <form> action (search forms submit to the clone, not the origin)
* - data-href / data-link / data-url on any element — page builders
* (Essential Blocks clickable cards, "Read More" buttons) navigate via JS
* from these, so leaving them absolute leaks every card click back to the
* WordPress origin on clones served from another domain.
*
* Same-origin links whose target was NOT crawled are left absolute (they still
* work, pointing at the live site); external links are untouched. Non-nav
* attributes (canonical/alternate <link> href, og:url, data-clipboard-text,
* JSON-LD) are never in scope — only the tag/attribute pairs above are matched.
*/
export function rewriteLinks(html, pageUrl, knownMap, origin) {
return html.replace(/(<a\b[^>]*?\bhref=)(["'])([^"']+)\2/gi, (full, pre, q, href) => {
let abs;
try { abs = new URL(href, pageUrl).href; } catch { return full; }
if (!abs.startsWith(origin)) return full;
const local = knownMap.get(normalizeUrl(abs));
return local ? `${pre}${q}${local}${q}` : full;
const swap = (value) => localTarget(value, pageUrl, knownMap, origin);
// <a href> / <area href> — attribute rewritten only inside these tags, so
// <link href> (canonical, feeds, preloads) can never be caught.
let out = html.replace(/<(?:a|area)\b[^>]*>/gi, (tag) =>
tag.replace(/(\bhref=)(["'])([^"']*)\2/i, (m, pre, q, v) => {
const local = swap(v);
return local ? `${pre}${q}${local}${q}` : m;
})
);
// <form action> — a search form posting to the origin breaks on the clone
// (CORS aside, results would render on the source site).
out = out.replace(/<form\b[^>]*>/gi, (tag) =>
tag.replace(/(\baction=)(["'])([^"']*)\2/i, (m, pre, q, v) => {
const local = swap(v);
return local ? `${pre}${q}${local}${q}` : m;
})
);
// JS click-navigation data attributes, wherever they appear.
out = out.replace(/(\bdata-(?:href|link|url)=)(["'])([^"']*)\2/gi, (m, pre, q, v) => {
const local = swap(v);
return local ? `${pre}${q}${local}${q}` : m;
});
return out;
}

@@ -25,2 +25,5 @@ // Builds the mirror manifest. It serves two audiences at once:

if (p.description) entry.description = p.description;
// /page/N/ archive pages are served but stay out of sitemap/llms/search
// (they duplicate the base listing — WordPress excludes them too).
if (p.paged) entry.paged = true;
if (p.contentKey) {

@@ -27,0 +30,0 @@ (content_to_urls[p.contentKey] ||= []).push(p.path);

// Emits the deployable wrapper around a captured mirror: a static Astro project
// that serves the captured pages (in public/) through the same nginx + regenerator
// + Docker model the rest of the toolkit uses.
import { mkdir, writeFile, copyFile } from 'node:fs/promises';
import { mkdir, writeFile, copyFile, rm } from 'node:fs/promises';
import { existsSync } from 'node:fs';

@@ -88,3 +88,3 @@ import { join } from 'node:path';

export async function writeMirrorProject(outDir, { siteUrl, deployOrigin = null, templatesDir, pageCount = 0, renderEngine = 'fetch' }) {
export async function writeMirrorProject(outDir, { siteUrl, deployOrigin = null, templatesDir, pageCount = 0, renderEngine = 'fetch', themed404 = false }) {
// siteUrl is the WordPress SOURCE (API endpoints); `site` in the generated

@@ -125,2 +125,16 @@ // config is the DEPLOYED origin, preserved across refreshes after a cutover.

SITE_URL=${site}
# ── Split-host cutover (optional) ────────────────────────────────────────────
# After cutover the rendered WordPress pages may live on a separate backend
# host (often bot-protected) while this clone serves the public domain.
# WP_FRONT_URL is where mirror-mode updates recapture full pages from
# (defaults to the WP_API_URL origin). Bot protection usually gates on
# User-Agent or a bypass header — configure both here.
#WP_FRONT_URL=${siteUrl}
#WP_FETCH_UA=Mozilla/5.0 (compatible; xcloud-headless-regenerator/1.0)
#WP_FETCH_HEADERS={"X-Bypass-Token":"…"}
# How webhook edits reach the served HTML: auto (default) recaptures
# page-builder/frozen pages and REST-patches plain posts; mirror always
# recaptures; rest always patches.
#REGEN_CONTENT_MODE=auto
`);

@@ -154,3 +168,10 @@

await writeFile(join(outDir, 'src/pages/404.astro'),
// 404 route: when the mirror captured the source site's themed 404 page it
// lives at public/404.html (pixel-faithful, served by nginx's error_page) —
// the Astro placeholder route would collide with it in dist/, so it's only
// emitted (and any prior captured file wins) when no themed capture exists.
if (themed404) {
await rm(join(outDir, 'src/pages/404.astro'), { force: true });
} else if (!existsSync(join(outDir, 'public', '404.html'))) {
await writeFile(join(outDir, 'src/pages/404.astro'),
`---

@@ -173,2 +194,3 @@ const site = import.meta.env.SITE_URL || '${site}';

`);
}

@@ -184,3 +206,3 @@ // dependency-manifest.json is written by the mirror command (rich manifest);

await writeFile(join(outDir, '.gitignore'), 'node_modules/\ndist/\n.env\n.mirror-asset-cache.json\n.mirror-image-meta.json\n');
await writeFile(join(outDir, '.gitignore'), 'node_modules/\ndist/\n.env\n.mirror-asset-cache.json\n.mirror-image-meta.json\nregenerator/.sync-state.json\n');

@@ -201,2 +223,6 @@ // docker-compose.yml — xCloud's Docker-from-Git deploy is Compose-based

- REBUILD_HOOK_URL=\${REBUILD_HOOK_URL:-}
- WP_FRONT_URL=\${WP_FRONT_URL:-}
- WP_FETCH_UA=\${WP_FETCH_UA:-}
- WP_FETCH_HEADERS=\${WP_FETCH_HEADERS:-}
- REGEN_CONTENT_MODE=\${REGEN_CONTENT_MODE:-}
restart: unless-stopped

@@ -210,2 +236,4 @@ `);

[join(templatesDir, 'regenerator', 'server.js'), join(reg, 'server.js')],
[join(templatesDir, 'regenerator', 'localize.js'), join(reg, 'localize.js')],
[join(templatesDir, 'regenerator', 'sync-new.mjs'), join(reg, 'sync-new.mjs')],
[join(templatesDir, 'regenerator', 'package.json'), join(reg, 'package.json')],

@@ -212,0 +240,0 @@ [join(templatesDir, 'astro', 'wp-data.js'), join(reg, 'wp-data.js')],

@@ -16,2 +16,3 @@ #!/usr/bin/env node

import { renderCards } from './card-html.js';
import { localizeRecapturedHtml, portKitHeadTags } from './localize.js';

@@ -37,2 +38,60 @@ const execFileAsync = promisify(execFile);

// Split-host cutovers: after production cutover the rendered WordPress pages
// often live on a separate (sometimes bot-protected) backend host while the
// clone serves the public domain. WP_FRONT_URL is where full pages are
// recaptured from (defaults to the WP_API_URL origin); WP_FETCH_UA /
// WP_FETCH_HEADERS shape every request to the backend — bot protection
// commonly gates on User-Agent or a bypass header.
const WP_FRONT_URL = (process.env.WP_FRONT_URL || '').replace(/\/+$/, '');
const WP_FETCH_UA = process.env.WP_FETCH_UA ||
'Mozilla/5.0 (compatible; xcloud-headless-regenerator/1.0)';
let WP_FETCH_HEADERS = {};
try { WP_FETCH_HEADERS = JSON.parse(process.env.WP_FETCH_HEADERS || '{}') || {}; }
catch { console.error('[regenerator] WP_FETCH_HEADERS is not valid JSON — ignoring'); }
// How edits reach the served HTML: 'rest' always patches the content region
// from REST content.rendered; 'mirror' always recaptures the fully rendered
// page from the front host; 'auto' (default) recaptures frozen pages (no
// content sentinels) and page-builder-authored content — REST injection can't
// carry the per-block <head> CSS builders emit, so patching those collapses
// the design — and patches plain Gutenberg/classic content surgically.
const REGEN_CONTENT_MODE = ['rest', 'mirror', 'auto'].includes(process.env.REGEN_CONTENT_MODE)
? process.env.REGEN_CONTENT_MODE : 'auto';
// Class fingerprints of page builders whose head CSS REST injection loses.
const BUILDER_CONTENT_RE = /class=["'][^"']*\b(?:eb-|elementor-|brxe-|fl-builder|et_pb_|vc_row|uagb-|kb-row|kt-blocks)/;
/** fetch() against the WordPress backend with the configured UA/headers. */
export function fetchWp(url, init = {}) {
return fetch(url, {
...init,
headers: { 'User-Agent': WP_FETCH_UA, ...WP_FETCH_HEADERS, ...(init.headers || {}) },
});
}
// post_type slug → REST base, learned from /wp/v2/types once per process.
// Webhooks include rest_base since bridge-plugin 1.1; older plugins (or bare
// payloads) fall back to this lookup so docs/changelogs/every CPT stop being
// fetched from /wp/v2/posts/{id} (a guaranteed 404).
let TYPE_BASE_CACHE = null;
export async function resolveRestBase(postType, hinted = '') {
if (hinted) return hinted;
if (!postType || postType === 'post') return 'posts';
if (postType === 'page') return 'pages';
if (!TYPE_BASE_CACHE) {
try {
const r = await fetchWp(`${WP_API_URL}/wp/v2/types`);
if (r.ok) {
const types = await r.json();
const cache = {};
for (const t of Object.values(types || {})) {
if (t && t.slug && t.rest_base) cache[t.slug] = t.rest_base;
}
TYPE_BASE_CACHE = cache;
}
} catch { /* transient — retry on the next event */ }
}
return (TYPE_BASE_CACHE && TYPE_BASE_CACHE[postType]) || postType;
}
const dedup = new Map(); // eventKey → timestamp, for 60s dedup window

@@ -258,3 +317,3 @@

try {
const res = await fetch(`${WP_API_URL}/wp/v2/posts?per_page=${POSTS_PER_PAGE}&page=1&_embed=1&orderby=date&order=desc`);
const res = await fetchWp(`${WP_API_URL}/wp/v2/posts?per_page=${POSTS_PER_PAGE}&page=1&_embed=1&orderby=date&order=desc`);
if (!res.ok) return;

@@ -285,6 +344,8 @@ const raw = await res.json();

async function regeneratePage(urlPath, payload) {
// Fetch fresh content from WP REST API
const { id, post_type } = payload.payload || {};
const type = post_type === 'page' ? 'pages' : 'posts';
const wpData = await fetch(`${WP_API_URL}/wp/v2/${type}/${id}?_embed`).then(r => r.json());
// Fetch fresh content from WP REST API — through the post type's OWN rest
// base (docs/changelogs/every CPT lives under /wp/v2/{rest_base}, not
// /wp/v2/posts).
const { id, post_type, rest_base } = payload.payload || {};
const type = await resolveRestBase(post_type, rest_base);
const wpData = await fetchWp(`${WP_API_URL}/wp/v2/${type}/${id}?_embed`).then(r => r.json());

@@ -310,8 +371,22 @@ const filePath = urlPathToFile(urlPath);

const existing = existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
// Mirror-mode recapture: page-builder content emits per-block ID-scoped CSS
// into <head> — REST content-injection loses it, so the design collapses and
// block assets break on exactly the pages that look designed. Recapture the
// fully rendered page from the front host and localize it onto the clone;
// the kit's injected head layer (twin link, speculation rules, JSON-LD) is
// ported over from the previously served page. Falls back to REST patching
// when the fetch fails (bot-blocked, origin down).
const hasSentinels = /data-xcloud=["']content-start["']/.test(existing);
const wantRecapture = REGEN_CONTENT_MODE === 'mirror' ||
(REGEN_CONTENT_MODE === 'auto' && !!existing && (!hasSentinels || BUILDER_CONTENT_RE.test(content)));
let html = wantRecapture ? await recaptureMirrorHtml(urlPath, wpData, existing) : null;
// Prefer surgically patching the existing, fully-themed page: swap only the
// content region (and <title>) so the original CSS, fonts, header and footer
// are preserved. Fall back to a themed shell only when the page is brand new.
let html;
if (existsSync(filePath)) {
const existing = readFileSync(filePath, 'utf8');
if (html != null) {
// recaptured above — nothing to patch
} else if (existing) {
html = patchContentRegion(existing, { title, content });

@@ -387,2 +462,37 @@ } else {

// Where the fully rendered page for a route is fetched from: WP_FRONT_URL
// when configured (split-host cutover), else the permalink WordPress reports,
// else the WP_API_URL origin + route.
function frontUrlFor(urlPath, link) {
if (WP_FRONT_URL) return WP_FRONT_URL + urlPath;
if (link) {
try { return new URL(link).href; } catch { /* fall through */ }
}
try { return new URL(String(WP_API_URL)).origin + urlPath; } catch { return ''; }
}
// Fetch + localize a fully rendered page for mirror-mode updates. Returns the
// clone-ready HTML, or null so the caller falls back to REST patching.
async function recaptureMirrorHtml(urlPath, wpData, existing) {
const front = frontUrlFor(urlPath, wpData && wpData.link);
if (!front) return null;
try {
const r = await fetchWp(front, { redirect: 'follow' });
if (!r.ok) throw new Error('HTTP ' + r.status);
let html = await r.text();
if (!/<html[\s>]/i.test(html)) throw new Error('response is not an HTML document');
const origins = [];
try { origins.push(new URL(front).origin); } catch { /* no front origin */ }
try { origins.push(new URL(String(WP_API_URL)).origin); } catch { /* no api origin */ }
if (wpData && wpData.link) { try { origins.push(new URL(wpData.link).origin); } catch { /* ignore */ } }
html = localizeRecapturedHtml(html, { origins, htmlDir: HTML_DIR, deployedOrigin: deployedOrigin() });
html = portKitHeadTags(existing, html);
console.log(`[regenerator] mirror-recaptured ${urlPath} from ${front}`);
return html;
} catch (err) {
console.error(`[regenerator] mirror recapture failed for ${urlPath} (falling back to REST patch):`, err.message);
return null;
}
}
// The static sitemap (and any 50k split chunk) is generator-owned — created

@@ -698,3 +808,3 @@ // posts must become discoverable and deleted routes must stop being

const params = new URLSearchParams({ per_page: '20', page: '1', orderby: 'date', order: 'desc', _fields: 'link,slug,title,excerpt,date_gmt' });
const r = await fetch(`${WP_API_URL}/wp/v2/posts?${params}`);
const r = await fetchWp(`${WP_API_URL}/wp/v2/posts?${params}`);
if (!r.ok) return; // keep the existing feed on a failed fetch

@@ -735,3 +845,3 @@ const raw = await r.json();

params.set('page', String(page));
const rn = await fetch(`${WP_API_URL}/wp/v2/posts?${params}`);
const rn = await fetchWp(`${WP_API_URL}/wp/v2/posts?${params}`);
if (!rn.ok) break;

@@ -738,0 +848,0 @@ const more = await rn.json();