@speed.press/kit
Advanced tools
| // 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(/&/g, '&').replace(/�?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 ); | ||
| } | ||
| } |
+146
-9
@@ -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(); |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 4 instances
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
605539
7.16%66
4.76%10854
6.45%36
-2.7%73
14.06%