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
19 changes: 19 additions & 0 deletions docs/analytics/editorial-analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,25 @@ This keeps retries and repeated submissions from inflating conversion totals.

Raw events are not automatically "unique readers". Distinct-user language must only be used when the provider/report explicitly uses a distinct visitor/session measure.

## AI answer-engine traffic (GEO)

Two signals measure how the publication performs in AI answer engines.

**Readers referred by an AI answer engine (PostHog).** Right after `posthog.init`, the bootstrap classifies the landing page's referrer host and `utm_source` (rules in `src/lib/analytics/aiReferrer.ts`: ChatGPT, Perplexity, Gemini, Copilot, Claude, DeepSeek, Mistral, Meta AI, Grok). On a match it:

- registers the session property `ai_referrer` (e.g. `chatgpt`), so every event of that visit — page views, `article_view`, reading milestones, `source_click` — can be broken down by answer engine;
- captures one `ai_referral_landing` event with `ai_referrer` and `landing_path`.

Useful PostHog insights: `ai_referral_landing` trend by `ai_referrer`; `article_view` filtered on `ai_referrer` is set, broken down by `slug`; read-through (`article_read_75` / `article_view`) for AI-referred vs. other sessions.

**AI crawler hits (hosting logs).** The proxy and the `/llms.txt`, `/llms-full.txt` and `/{locale}/posts/{slug}.md` handlers write one JSON log line per request from a known AI crawler (`src/seo/aiCrawlers.ts`):

```json
{"event":"ai_crawler_hit","crawler":"GPTBot","path":"/fr/posts/example"}
```

On Vercel, filter the runtime logs on `ai_crawler_hit`. Responses served from the CDN cache (for example `/llms.txt` within its `s-maxage`) do not reach the handler and are not logged, so treat these counts as a lower bound and a trend, not an exact total.

## Environment and privacy rules

- localhost: disabled;
Expand Down
22 changes: 22 additions & 0 deletions next-sitemap.config.cjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
const SITE_URL = 'https://insight.trigenys.com'

// Answer engines and AI search crawlers are named explicitly so a future change
// to the wildcard rule cannot silently remove the publication from AI answers.
// A machine-readable guide for these agents is served at /llms.txt.
const AI_CRAWLERS = [
'OAI-SearchBot',
'ChatGPT-User',
'GPTBot',
'Claude-SearchBot',
'Claude-User',
'ClaudeBot',
'PerplexityBot',
'Perplexity-User',
'Google-Extended',
'Applebot-Extended',
'Bingbot',
]

/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: SITE_URL,
Expand All @@ -11,6 +28,11 @@ module.exports = {
userAgent: '*',
disallow: '/admin/*',
},
...AI_CRAWLERS.map((userAgent) => ({
userAgent,
allow: '/',
disallow: '/admin/*',
})),
],
additionalSitemaps: [
`${SITE_URL}/pages-sitemap.xml`,
Expand Down
9 changes: 9 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ const nextConfig: NextConfig = {
},
reactStrictMode: true,
redirects,
async rewrites() {
return [
{
// Markdown rendition of an article for AI agents (see /llms.txt).
source: '/:locale(fr|en)/posts/:slug.md',
destination: '/markdown/:locale/:slug',
},
]
},
turbopack: {
root: path.resolve(dirname),
},
Expand Down
33 changes: 0 additions & 33 deletions public/llms.txt

This file was deleted.

80 changes: 80 additions & 0 deletions src/app/(frontend)/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2321,3 +2321,83 @@ a {
border-radius: 8px;
}
}

/* Editor-written key takeaways (replace the automatic section highlights). */
.article-takeaways {
display: grid;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
counter-reset: takeaway;
}

.article-takeaways li {
position: relative;
border: 1px solid #dfded7;
border-radius: 10px;
background: #ffffff;
padding: 16px 20px 16px 58px;
color: #33393e;
font-size: 15px;
line-height: 1.6;
counter-increment: takeaway;
}

.article-takeaways li::before {
content: counter(takeaway, decimal-leading-zero);
position: absolute;
top: 17px;
left: 20px;
color: #d76b19;
font-size: 10px;
font-weight: 850;
letter-spacing: 0.14em;
}

.article-reading-main > .article-faq {
width: 100%;
max-width: 100% !important;
margin-inline: 0 !important;
}

.article-faq dl {
margin: 0;
}

.article-faq dl > div {
padding: 14px 0;
border-top: 1px solid #dfded7;
}

.article-faq dl > div:first-child {
border-top: 0;
padding-top: 4px;
}

.article-faq dt {
margin-bottom: 6px;
color: #102f52;
font-weight: 700;
font-size: 15px;
line-height: 1.45;
}

.article-faq dd {
margin: 0;
color: #4e555b;
font-size: 14px;
line-height: 1.65;
}

@media (max-width: 640px) {
.article-takeaways li {
padding: 14px 16px 14px 50px;
font-size: 14px;
}

.article-takeaways li::before {
left: 16px;
top: 15px;
}
}
16 changes: 16 additions & 0 deletions src/app/(frontend)/llms-full.txt/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { logAiCrawlerHit } from '@/seo/aiCrawlers'
import { buildLlmsFullTxt } from '@/seo/llms'
import { getLlmsFullPosts } from '@/seo/llmsData'

export async function GET(request: Request) {
logAiCrawlerHit(request.headers.get('user-agent'), '/llms-full.txt')

const body = buildLlmsFullTxt(await getLlmsFullPosts())

return new Response(body, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
})
}
16 changes: 16 additions & 0 deletions src/app/(frontend)/llms.txt/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { logAiCrawlerHit } from '@/seo/aiCrawlers'
import { buildLlmsTxt } from '@/seo/llms'
import { getLlmsIndexPosts } from '@/seo/llmsData'

export async function GET(request: Request) {
logAiCrawlerHit(request.headers.get('user-agent'), '/llms.txt')

const body = buildLlmsTxt(await getLlmsIndexPosts())

return new Response(body, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
})
}
46 changes: 46 additions & 0 deletions src/app/(frontend)/markdown/[locale]/[slug]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { getPayload } from 'payload'
import config from '@payload-config'

import { isSiteLocale } from '@/i18n/config'
import { logAiCrawlerHit } from '@/seo/aiCrawlers'
import { renderPostMarkdown } from '@/seo/llms'
import { isPublicLocalizedPost } from '@/seo/postsSitemap'
import { absoluteCanonicalURL } from '@/seo/structuredData'

// Served publicly as /{locale}/posts/{slug}.md through a rewrite in next.config.
type Args = { params: Promise<{ locale: string; slug: string }> }

export async function GET(request: Request, { params }: Args) {
const { locale, slug: rawSlug } = await params
const slug = decodeURIComponent(rawSlug)
logAiCrawlerHit(request.headers.get('user-agent'), `/${locale}/posts/${slug}.md`)

if (!isSiteLocale(locale)) return new Response('Not found', { status: 404 })

const payload = await getPayload({ config })
const result = await payload.find({
collection: 'posts',
overrideAccess: false,
draft: false,
depth: 1,
fallbackLocale: false,
locale,
limit: 1,
pagination: false,
where: {
and: [{ slug: { equals: slug } }, { _status: { equals: 'published' } }],
},
})

const post = result.docs[0]
if (!post || !isPublicLocalizedPost(post)) return new Response('Not found', { status: 404 })

return new Response(`${renderPostMarkdown(locale, post)}\n`, {
headers: {
'Content-Type': 'text/markdown; charset=utf-8',
// The HTML article stays the canonical, indexable document.
Link: `<${absoluteCanonicalURL(`/${locale}/posts/${slug}`)}>; rel="canonical"`,
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=86400',
},
})
}
2 changes: 2 additions & 0 deletions src/collections/Posts/hooks/revalidatePost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import {
indexNowPostFingerprint,
submitIndexNowUrls,
} from '@/seo/indexNow'
import { LLMS_CACHE_TAG } from '@/seo/llms'

function revalidateEditorialSitemaps() {
revalidateTag('posts-sitemap', 'max')
revalidateTag('news-sitemap', 'max')
revalidateTag(LLMS_CACHE_TAG, 'max')
}

// These are the public URLs, not just the legacy /posts route. Keep landing
Expand Down
55 changes: 55 additions & 0 deletions src/collections/Posts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,61 @@ export const Posts: CollectionConfig<'posts'> = {
],
label: 'Content',
},
{
// Short, factual, quotable material that answer engines can lift
// verbatim. Both fields are optional and only rendered when filled.
fields: [
{
name: 'keyTakeaways',
type: 'array',
label: 'À retenir',
labels: { singular: 'Point clé', plural: 'Points clés' },
localized: true,
maxRows: 5,
admin: {
description:
'3 à 5 constats factuels et autonomes, compréhensibles sans lire l’article. Remplace la lecture rapide générée automatiquement.',
initCollapsed: true,
},
fields: [
{
name: 'text',
type: 'textarea',
maxLength: 280,
required: true,
},
],
},
{
name: 'faq',
type: 'array',
label: 'Questions fréquentes',
labels: { singular: 'Question', plural: 'Questions' },
localized: true,
maxRows: 8,
admin: {
description:
'Questions que se pose réellement le lecteur, avec une réponse directe de 1 à 3 phrases. Affichées en fin d’article.',
initCollapsed: true,
},
fields: [
{
name: 'question',
type: 'text',
maxLength: 200,
required: true,
},
{
name: 'answer',
type: 'textarea',
maxLength: 800,
required: true,
},
],
},
],
label: 'Réponses clés',
},
{
fields: [
{
Expand Down
Loading
Loading