From 516686e3823c079303d7a3f8fe539324c10b3b4b Mon Sep 17 00:00:00 2001 From: EagleFox31 <86088743+EagleFox31@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:31:20 +0100 Subject: [PATCH 1/3] fix(seo): remove sitewide crawler loading shell and add HTTP smoke checks --- scripts/seo/public-crawl-smoke.mjs | 97 +++++++++++++++++++ .../(frontend)/{ => en/search}/loading.tsx | 1 + src/app/(frontend)/fr/search/loading.tsx | 6 ++ tests/unit/seoPublicCrawl.spec.ts | 29 ++++++ 4 files changed, 133 insertions(+) create mode 100644 scripts/seo/public-crawl-smoke.mjs rename src/app/(frontend)/{ => en/search}/loading.tsx (65%) create mode 100644 src/app/(frontend)/fr/search/loading.tsx create mode 100644 tests/unit/seoPublicCrawl.spec.ts diff --git a/scripts/seo/public-crawl-smoke.mjs b/scripts/seo/public-crawl-smoke.mjs new file mode 100644 index 0000000..f51deaf --- /dev/null +++ b/scripts/seo/public-crawl-smoke.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node + +// Zero-dependency HTTP smoke test for search engine accessibility. +// Usage: node scripts/seo/public-crawl-smoke.mjs [https://insight.trigenys.com] +// Pass a preview deployment URL to test its routes while still enforcing the +// production canonical URLs emitted by the site. +const base = new URL(process.argv[2] || process.env.SEO_SMOKE_BASE_URL || 'https://insight.trigenys.com') +const canonicalHost = 'insight.trigenys.com' +const failures = [] + +function assert(condition, message) { + if (!condition) { + failures.push(message) + console.error(`FAIL ${message}`) + } else { + console.log(`PASS ${message}`) + } +} + +async function request(path) { + const url = new URL(path, base) + const response = await fetch(url, { + headers: { 'User-Agent': 'TrigenysSEO-P0-Smoke/1.0' }, + signal: AbortSignal.timeout(20000), + redirect: 'follow', + }) + const text = await response.text() + assert(response.ok, `${url.pathname}: HTTP ${response.status}`) + return { text, response, url } +} + +function documentWithoutScripts(html) { + return html.replace(/]*>[\s\S]*?<\/script>/gi, '') +} + +function sitemapLocs(xml) { + return [...xml.matchAll(/\s*([^<]+?)\s*<\/loc>/gi)] + .map((match) => match[1].replace(/&/g, '&')) +} + +async function main() { + const { text: robots } = await request('/robots.txt') + assert(robots.includes('Sitemap:') && robots.includes('insight.trigenys.com'), 'robots.txt lists canonical sitemaps') + + const { text: xml } = await request('/posts-sitemap.xml') + assert(xml.includes(' 0, 'posts sitemap contains at least one article') + assert( + articleURLs.every((url) => { + try { + return new URL(url).host === canonicalHost && new URL(url).protocol === 'https:' + } catch { + return false + } + }), + 'all article sitemap URLs point to the canonical HTTPS host', + ) + + const { text: newsXML } = await request('/news-sitemap.xml') + assert(newsXML.includes(' new URL(url).host === canonicalHost), + 'News sitemap URLs point to the canonical host', + ) + + for (const locale of ['fr', 'en']) { + const { text: homeHTML } = await request(`/${locale}`) + const visibleHome = documentWithoutScripts(homeHTML) + assert(/ new URL(url).pathname.startsWith(`/${locale}/posts/`)) + if (!article) { + console.log(`SKIP ${locale} article check: no localized article in sitemap`) + continue + } + + const { text: articleHTML } = await request(new URL(article).pathname) + const visibleArticle = documentWithoutScripts(articleHTML) + assert(/ } diff --git a/src/app/(frontend)/fr/search/loading.tsx b/src/app/(frontend)/fr/search/loading.tsx new file mode 100644 index 0000000..007069f --- /dev/null +++ b/src/app/(frontend)/fr/search/loading.tsx @@ -0,0 +1,6 @@ +import { SiteSkeleton } from '@/components/insights/SiteSkeleton' + +// Search is interactive and is not an indexable editorial article. +export default function Loading() { + return +} diff --git a/tests/unit/seoPublicCrawl.spec.ts b/tests/unit/seoPublicCrawl.spec.ts new file mode 100644 index 0000000..0793960 --- /dev/null +++ b/tests/unit/seoPublicCrawl.spec.ts @@ -0,0 +1,29 @@ +import { access, readFile } from 'node:fs/promises' +import path from 'node:path' + +import { describe, expect, it } from 'vitest' + +describe('P0: public crawl entry points', () => { + it('does not expose the global frontend loading skeleton to article crawlers', async () => { + const filename = path.join(process.cwd(), 'src/app/(frontend)/loading.tsx') + await expect(access(filename)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it('preserves scoped search feedback rather than removing all loading states', async () => { + for (const locale of ['fr', 'en']) { + const filename = path.join(process.cwd(), `src/app/(frontend)/${locale}/search/loading.tsx`) + const code = await readFile(filename, 'utf8') + expect(code).toContain('') + } + }) + + it('provides a repeatable HTTP smoke check for live editorial markup', async () => { + const script = await readFile( + path.join(process.cwd(), 'scripts/seo/public-crawl-smoke.mjs'), + 'utf8', + ) + expect(script).toContain('posts-sitemap.xml') + expect(script).toContain('article-reading-main') + expect(script).toContain('rel="canonical"') + }) +}) From 75a25b68d2c25185b83a8cd2af40fb595c67c153 Mon Sep 17 00:00:00 2001 From: EagleFox31 <86088743+EagleFox31@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:39:26 +0100 Subject: [PATCH 2/3] feat(ux): keep editorial skeleton during client navigation without blocking crawl HTML --- src/app/(frontend)/layout.tsx | 2 + .../insights/NavigationSkeleton.tsx | 99 +++++++++++++++++++ src/components/insights/SiteSkeleton.tsx | 5 +- src/components/insights/navigationSkeleton.ts | 11 +++ tests/unit/seoPublicCrawl.spec.ts | 28 ++++++ 5 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 src/components/insights/NavigationSkeleton.tsx create mode 100644 src/components/insights/navigationSkeleton.ts diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index af9f816..b43f5ef 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -7,6 +7,7 @@ import { Fraunces, Inter } from 'next/font/google' import React from 'react' import { AdminBar } from '@/components/AdminBar' +import { NavigationSkeleton } from '@/components/insights/NavigationSkeleton' import { Footer } from '@/Footer/Component' import { Header } from '@/Header/Component' import { Providers } from '@/providers' @@ -40,6 +41,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
+ {children}