Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 27 additions & 26 deletions functions/_middleware.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
// Cloudflare Pages Edge Middleware — Host, Protocol & Trailing-Slash Canonicalisation
//
// WHY THIS CHANGED
// The previous version redirected www → apex but did NOT normalise the
// trailing slash. Because astro.config.mjs sets `trailingSlash: 'always'`,
// a request for:
// https://www.stackarchitect.xyz/autocrat-quota-fix
// produced TWO redirects:
// 301 → https://stackarchitect.xyz/autocrat-quota-fix (this middleware)
// 301 → https://stackarchitect.xyz/autocrat-quota-fix/ (Pages slash rule)
// WHY THIS CHANGED (2026-08-17)
// The previous version's non-primary-host branch fell through to
// `context.next()`, which SERVED the full site on any host that is neither the
// apex nor a www.* subdomain. That included every per-deployment preview host
// (`<hash>.stackarchitect.pages.dev`) — each one an indexable duplicate of the
// entire site. Those hosts are now redirected to the apex instead of served.
//
// GSC's 6-month export shows Google holding exactly those un-slashed www URLs
// (e.g. www.stackarchitect.xyz/autocrat-quota-fix, 982 impressions). Every hop
// slows consolidation and is a likely contributor to the "Redirect error" (19)
// and "Page with redirect" (7) buckets in the Coverage report.
// The single-hop www/protocol/trailing-slash behaviour is UNCHANGED and remains
// verified working:
// www.stackarchitect.xyz/autocrat-quota-fix
// → one 301 → stackarchitect.xyz/autocrat-quota-fix/
// stackarchitect.xyz/autocrat-quota-fix
// → one 301 → stackarchitect.xyz/autocrat-quota-fix/
//
// This version resolves host, protocol AND trailing slash in ONE 301.
//
// Deliberately NOT redirected:
// Deliberately NOT slash-redirected:
// • /api/* — Pages Functions
// • /go/* — REVENUE CRITICAL. public/_redirects already declares
// both slash variants for every affiliate cloak. Adding a
Expand All @@ -25,39 +23,35 @@
// become 301 → 302 instead of a single 302. Some affiliate
// networks drop tracking parameters across extra hops.
// • paths with a file extension (.xml, .txt, .json, .png, .csv …) — these
// must NOT gain a trailing slash or they 404. This was the main risk of
// naively appending "/" to everything.
// must NOT gain a trailing slash or they 404.

const PRIMARY_HOST = 'stackarchitect.xyz';

// Anything with an extension in the last segment is a file, not a page.
const FILE_RE = /\.[a-zA-Z0-9]{2,5}$/;

export async function onRequest(context) {
const url = new URL(context.request.url);
let changed = false;

// 1. Force HTTPS
if (url.protocol === 'http:') {
url.protocol = 'https:';
changed = true;
}

// 2. Force apex host (strip www.)
if (url.hostname.startsWith('www.')) {
url.hostname = url.hostname.replace(/^www\./, '');
changed = true;
}

// Safety: never rewrite a host we don't own the canonical for.
// Any remaining non-primary host (pages.dev, preview deployments, stale
// custom domains) is redirected to the apex rather than served.
if (url.hostname !== PRIMARY_HOST) {
return changed ? Response.redirect(url.toString(), 301) : context.next();
url.hostname = PRIMARY_HOST;
changed = true;
}

// 3. Force trailing slash on page routes only
const p = url.pathname;
const isApi = p.startsWith('/api/');
const isGo = p.startsWith('/go/'); // affiliate cloaks — see note above
const isGo = p.startsWith('/go/'); // affiliate cloaks — must not gain a slash hop
const isFile = FILE_RE.test(p.split('/').pop() || '');

if (!isApi && !isGo && !isFile && !p.endsWith('/')) {
Expand All @@ -69,5 +63,12 @@ export async function onRequest(context) {
return Response.redirect(url.toString(), 301);
}

return context.next();
const response = await context.next();
const host = new URL(context.request.url).hostname;
if (host !== PRIMARY_HOST) {
const patched = new Response(response.body, response);
patched.headers.set('X-Robots-Tag', 'noindex, nofollow');
return patched;
}
return response;
}
78 changes: 78 additions & 0 deletions functions/api/capture-email.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
const SYSTEME_API_URL = "https://api.systeme.io/api/contacts";

const ALLOWED_TAGS = new Set([
"guides-capture",
"homepage-capture",
"klaviyo-calculator",
"scanner-plan",
"stack-capture",
]);

export async function onRequestPost(context) {
const { request, env } = context;

const cors = {
"Access-Control-Allow-Origin": "https://stackarchitect.xyz",
"Content-Type": "application/json",
};

if (!env.SYSTEME_API_KEY) {
console.error("SYSTEME_API_KEY binding is not configured");
return json({ error: "Capture unavailable" }, 503, cors);
}

let body;
try {
body = await request.json();
} catch {
return json({ error: "Invalid JSON" }, 400, cors);
}

if (body === null || typeof body !== "object" || Array.isArray(body)) {
return json({ error: "Body must be a JSON object" }, 400, cors);
}

const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
if (!email || email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return json({ error: "Invalid email" }, 400, cors);
}

const tag = ALLOWED_TAGS.has(body.tag) ? body.tag : "site-capture";

let upstream;
try {
upstream = await fetch(SYSTEME_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": env.SYSTEME_API_KEY,
},
body: JSON.stringify({ email, tags: [{ name: tag }] }),
});
} catch {
return json({ error: "Upstream unreachable" }, 502, cors);
}

// 409 = contact already exists; a success from the visitor's perspective.
if (upstream.ok || upstream.status === 409) {
return json({ success: true }, 200, cors);
}

console.error("Systeme.io error", upstream.status);
return json({ error: "Capture failed" }, 502, cors);
}

export async function onRequestOptions() {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "https://stackarchitect.xyz",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}

function json(payload, status, headers) {
return new Response(JSON.stringify(payload), { status, headers });
}
15 changes: 13 additions & 2 deletions src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -1308,7 +1308,11 @@ details.faq-item-c[open] summary .faq-ico::before{transform:rotate(225deg);top:6
var e = (emailEl ? emailEl.value : '').trim();
if (!e || !e.includes('@')) { if (emailEl) { emailEl.style.borderColor = 'var(--red)'; emailEl.focus(); } return; }
if (emailEl) emailEl.style.borderColor = '';
window.open('https://stackarchitect.beehiiv.com/subscribe?email=' + encodeURIComponent(e), '_blank');
fetch('/api/capture-email/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: e, tag: 'scanner-plan' })
}).catch(function () {});
var plan = document.getElementById('sc-plan'); if(plan) plan.style.display = 'none';
var done = document.getElementById('sc-done'); if(done) done.style.display = 'block';
};
Expand All @@ -1320,7 +1324,14 @@ window.gfGet = function() {
var e = (emailEl ? emailEl.value : '').trim();
if (!e || !e.includes('@')) { if (emailEl) { emailEl.style.borderColor = 'var(--red)'; emailEl.focus(); } return; }
if (emailEl) emailEl.style.borderColor = '';
window.open('https://stackarchitect.beehiiv.com/subscribe?email=' + encodeURIComponent(e), '_blank');
fetch('/api/capture-email/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: e, tag: 'homepage-capture' })
}).catch(function () {});
// Single popup only — the PDF is the thing the visitor actually asked for, and
// one window.open per gesture survives mobile popup blocking. Signup is now
// handled by the fetch above rather than a second window.
window.open('https://d1yei2z3i6k35z.cloudfront.net/16370908/69a2ac505857c_The_Master_Library.pdf', '_blank');
var form = document.getElementById('gf-form'); if(form) form.style.display = 'none';
var done = document.getElementById('gf-done'); if(done) done.style.display = 'block';
Expand Down
12 changes: 2 additions & 10 deletions src/pages/replace-klaviyo-free.astro
Original file line number Diff line number Diff line change
Expand Up @@ -1068,18 +1068,10 @@ const tocHeadings = [
}
if (inp) inp.classList.remove('error');

// Beehiiv subscription (replace pub ID with yours)
fetch('https://api.beehiiv.com/v2/publications/pub_YOUR_PUBLICATION_ID/subscriptions', {
fetch('/api/capture-email/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email,
reactivate_existing: true,
send_welcome_email: true,
utm_source: 'klaviyo-calculator',
utm_medium: 'website',
utm_campaign: 'klaviyo-migration-checklist'
})
body: JSON.stringify({ email: email, tag: 'klaviyo-calculator' })
}).catch(function () {});

document.getElementById('calc-capture-form').style.display = 'none';
Expand Down
6 changes: 3 additions & 3 deletions src/pages/shopify-automation-guides.astro
Original file line number Diff line number Diff line change
Expand Up @@ -1026,10 +1026,10 @@ document.getElementById('guides-submit')?.addEventListener('click', function ()
return;
}
el.style.borderColor = '';
fetch('https://api.systeme.io/api/contacts', {
fetch('/api/capture-email/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': 'kdsymoc8fwhwwc604vrofp4jt0rb5bjosmzs5dqd4r8suo4avx4xsik4ixqvwgo4' },
body: JSON.stringify({ email, tags: [{ name: 'guides-capture' }] })
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, tag: 'guides-capture' })
}).catch(() => {});
if (typeof gtag !== 'undefined') {
gtag('event', 'guides_email_capture', { event_category: 'conversion', event_label: 'guides_hub' });
Expand Down
46 changes: 18 additions & 28 deletions src/pages/stack.astro
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,10 @@ const SITE = "https://stackarchitect.xyz";
const CANONICAL = `${SITE}/stack/`;
const UPDATED = "April 2026";

// ─── BEEHIIV CONFIG ──────────────────────────────────────────────────────────
// To get your publication ID:
// 1. Go to beehiiv.com → your publication → Settings → Publication Details
// 2. Copy the Publication ID (format: pub_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)
// Replace the value below. The subscribe endpoint then works automatically.
const BEEHIIV_PUB_ID = "YOUR_PUBLICATION_ID"; // e.g. pub_3f47e3c5-6ce3-479c-b015-863753ac8ea7
// ─── NEWSLETTER CONFIG ───────────────────────────────────────────────────────
// The newsletter form posts to /api/capture-email/ (Pages Function), which
// forwards to Systeme.io server-side using the SYSTEME_API_KEY binding.
// No publication ID or API credential belongs in this file.
// Your Beehiiv affiliate referral link — earns commission on paid upgrades
const BEEHIIV_AFFILIATE = "/go/beehiiv";

Expand Down Expand Up @@ -630,22 +628,11 @@ const schemaWebPage = {
<h2 id="nl-h2">Get the Stack Architect Weekly</h2>
<p class="nl-sub">New automation blueprints, Shopify profit-leak fixes, and tool updates — delivered every Tuesday. Free forever.</p>

<!--
BEEHIIV NATIVE FORM
Replace YOUR_PUBLICATION_ID with your pub ID from:
beehiiv.com → Settings → Publication Details → Publication ID
Format: pub_xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

OR: Go to Audience → Subscribe Forms → Create new form → Copy embed HTML
and paste the generated <iframe> snippet in place of this form.
-->
<form
class="nl-form"
id="nl-form"
action={`https://app.beehiiv.com/subscribing/sign_up_flow?publication_id=${BEEHIIV_PUB_ID}`}
method="GET"
target="_blank"
rel="noopener"
action="/api/capture-email/"
method="POST"
aria-label="Subscribe to Stack Architect newsletter"
onsubmit="handleNlSubmit(event)"
>
Expand All @@ -667,7 +654,7 @@ const schemaWebPage = {
<p class="nl-small">no spam · unsubscribe any time · 2,000+ subscribers across UK, US, AU &amp; CA</p>
</form>
<p class="nl-fallback" id="nl-success" style="display:none">
Redirecting to confirm your subscription — check your inbox after.
You're subscribed — check your inbox for the first dispatch.
</p>
<p class="nl-fallback">Or <a href="https://stackarchitect.beehiiv.com/" target="_blank" rel="noopener">open the subscribe page directly →</a></p>
</div>
Expand Down Expand Up @@ -810,15 +797,18 @@ const schemaWebPage = {
}
});

// Newsletter form — open beehiiv in new tab, show success message
// Newsletter form — POST to /api/capture-email/, show success message
function handleNlSubmit(e) {
const email = document.getElementById('nl-email').value;
if (!email) return;
// Show success note; form still submits to beehiiv via GET target=_blank
setTimeout(() => {
document.getElementById('nl-success').style.display = 'block';
document.getElementById('nl-form').reset();
}, 200);
e.preventDefault();
const email = (document.getElementById('nl-email').value || '').trim();
if (!email || !email.includes('@')) return;
fetch('/api/capture-email/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email, tag: 'stack-capture' })
}).catch(function () {});
document.getElementById('nl-success').style.display = 'block';
document.getElementById('nl-form').reset();
}


Expand Down
Loading