Skip to content
Open
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
84 changes: 84 additions & 0 deletions apps/caramel-app/e2e/seo-regression.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,4 +297,88 @@ test.describe('SEO regression gate (raw server HTML)', () => {
const body = await res.text()
expect(body).toContain('Caramel')
})

test('llms-full.txt is served for answer engines and carries the FAQ', async ({
page,
}) => {
// Measured 404 on prod 2026-09-11 (audit-findings.md "Host hygiene").
const res = await page.request.get('/llms-full.txt')
expect(res.ok()).toBe(true)
expect(res.headers()['content-type']).toContain('text/plain')
const body = await res.text()
expect(body).toContain('Caramel')
// The FAQ block is rendered from the SAME array as the landing FAQ
// (src/lib/faqItems.ts) — one question is enough to prove the wiring.
expect(body).toContain('Is Caramel really free?')
expect(body).toContain('/privacy')
})

test('home raw HTML links llms.txt (rel=alternate + a crawlable footer <a>)', async ({
page,
}) => {
const html = await (await page.request.get('/')).text()
// Next renders alternates.types as
// <link rel="alternate" type="text/plain" href="…/llms.txt" title="llms.txt"/>
// (href absolute via metadataBase). Attribute order is Next's, not ours.
expect(html).toMatch(
/<link rel="alternate" type="text\/plain" href="[^"]*\/llms\.txt"/,
)
// A client-only pointer is invisible to crawlers; the footer anchor is
// a plain <a href> in the server HTML.
expect(html).toMatch(/<a href="\/llms\.txt"[^>]*>llms\.txt<\/a>/)
})

test('home Organization JSON-LD keeps its entity anchors (@id, alternateName, sameAs, parentOrganization)', async ({
page,
}) => {
const html = await (await page.request.get('/')).text()
type Node = {
'@type'?: string
'@id'?: string
alternateName?: string[]
sameAs?: string[]
parentOrganization?: { '@type'?: string; url?: string }
}
const nodes = extractJsonLdBlocks(html).flatMap(block => {
const parsed = JSON.parse(block) as { '@graph'?: Node[] } & Node
return parsed['@graph'] ?? [parsed]
})
const org = nodes.find(node => node['@type'] === 'Organization')
expect(org, 'home must ship an Organization node').toBeTruthy()
expect(org!['@id']).toMatch(/#organization$/)
expect(org!.alternateName).toContain('Caramel coupon extension')
expect(Array.isArray(org!.sameAs)).toBe(true)
expect(org!.sameAs!.length).toBeGreaterThan(0)
for (const url of org!.sameAs!) {
expect(url).toMatch(/^https:\/\//)
}
expect(org!.parentOrganization?.['@type']).toBe('Organization')
expect(org!.parentOrganization?.url).toBe('https://devino.ca')
})

test('HSTS carries preload (next.config.mjs SECURITY_HEADERS, every context)', async ({
page,
}) => {
// Set by next.config.mjs headers(), so it holds on the hermetic server
// and on the deployed site alike. Measured missing on prod 2026-09-11.
const res = await page.request.get('/')
const hsts = res.headers()['strict-transport-security'] ?? ''
expect(hsts).toMatch(/max-age=31536000/)
expect(hsts).toMatch(/includeSubDomains/)
expect(hsts).toMatch(/preload/)
})

for (const routePath of ['/login', '/signup', '/verify']) {
test(`${routePath.slice(1)} raw HTML carries a robots noindex meta (belt-and-braces with robots.txt)`, async ({
page,
}) => {
// robots.txt only asks crawlers not to FETCH these; a link-discovered
// URL can still be indexed title-only unless the page says noindex.
const res = await page.request.get(routePath)
expect(res.ok(), `${routePath} must return 2xx`).toBe(true)
const html = await res.text()
expect(html).toMatch(/name="robots"[^>]*content="[^"]*noindex/)
expect(html).toMatch(/name="robots"[^>]*content="[^"]*nofollow/)
})
}
})
7 changes: 6 additions & 1 deletion apps/caramel-app/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,14 @@ const SECURITY_HEADERS = [
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), payment=()',
},
// `preload` is the hstspreload.org eligibility flag (max-age >= 1y +
// includeSubDomains + preload). Safe here because every subdomain that
// serves anything (dev.grabcaramel.com) is already https-only. Actually
// submitting the apex to the preload list is an owner action; the header
// alone changes nothing until then. Pinned by e2e/seo-regression.spec.ts.
{
key: 'Strict-Transport-Security',
value: 'max-age=31536000; includeSubDomains',
value: 'max-age=31536000; includeSubDomains; preload',
},
]

Expand Down
4 changes: 4 additions & 0 deletions apps/caramel-app/src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export const metadata: Metadata = {
alternates: {
canonical: canonicalUrl,
},
/* Belt-and-braces with robots.ts's Disallow: robots.txt only asks a
* crawler not to FETCH the page; a URL discovered through a link can still
* be indexed title-only unless the page itself says noindex. */
robots: { index: false, follow: false },
openGraph: {
type: 'website',
url: canonicalUrl,
Expand Down
2 changes: 2 additions & 0 deletions apps/caramel-app/src/app/(auth)/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export const metadata: Metadata = {
alternates: {
canonical: canonicalUrl,
},
/* Belt-and-braces with robots.ts's Disallow — see login/page.tsx. */
robots: { index: false, follow: false },
openGraph: {
type: 'website',
url: canonicalUrl,
Expand Down
2 changes: 2 additions & 0 deletions apps/caramel-app/src/app/(auth)/verify/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const metadata: Metadata = {
alternates: {
canonical: canonicalUrl,
},
/* Belt-and-braces with robots.ts's Disallow — see login/page.tsx. */
robots: { index: false, follow: false },
openGraph: {
type: 'website',
url: canonicalUrl,
Expand Down
15 changes: 13 additions & 2 deletions apps/caramel-app/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
SAFARI_APP_STORE_URL,
} from '@/lib/brandLinks'
import { BASE_URL } from '@/lib/env.client'
import { LLMS_TXT_ALTERNATE_TYPES } from '@/lib/seo/llmsTxtAlternate'
import '@/styles/globals.css'
import type { Metadata, Viewport } from 'next'
import { ReactNode } from 'react'
Expand All @@ -22,6 +23,7 @@ export const metadata: Metadata = {
title: 'Caramel | The Trusted Alternative To Honey For Finding Coupons',
description,
metadataBase: new URL(BASE_URL),
alternates: { types: LLMS_TXT_ALTERNATE_TYPES },
openGraph: {
type: 'website',
title: 'Caramel | The Trusted Alternative To Honey For Finding Coupons',
Expand Down Expand Up @@ -62,8 +64,12 @@ const THEME_INIT_SCRIPT = `(function(){var d=false;try{d=localStorage.getItem('t
// entity: "Caramel coupon extension" + the "grabcaramel" handle. The sameAs
// URLs come from src/lib/brandLinks.ts — the same constants the footer and
// llms.txt render, so the graph can't drift from the UI. Price 0 is real
// (free forever, no paid tier — see /pricing). Deliberately NO
// aggregateRating/review markup of any kind.
// (free forever, no paid tier — see /pricing). parentOrganization ties the
// entity to the company that ships it (the repo lives under the
// DevinoSolutions GitHub org; devino.ca is the company site, 200 on
// 2026-09-12). Deliberately NO aggregateRating/review markup of any kind.
// The @id / alternateName / sameAs / parentOrganization shape is pinned in
// raw HTML by e2e/seo-regression.spec.ts.
const ENTITY_STRUCTURED_DATA = {
'@context': 'https://schema.org',
'@graph': [
Expand All @@ -83,6 +89,11 @@ const ENTITY_STRUCTURED_DATA = {
DISCORD_INVITE_URL,
INSTAGRAM_URL,
],
parentOrganization: {
'@type': 'Organization',
name: 'Devino Solutions',
url: 'https://devino.ca',
},
},
{
'@type': 'SoftwareApplication',
Expand Down
101 changes: 101 additions & 0 deletions apps/caramel-app/src/app/llms-full.txt/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
CHROME_WEB_STORE_URL,
DISCORD_INVITE_URL,
EDGE_ADDONS_URL,
FIREFOX_ADDONS_URL,
GITHUB_REPO_URL,
SAFARI_APP_STORE_URL,
} from '@/lib/brandLinks'
import { BASE_URL } from '@/lib/env.client'
import { faqItems } from '@/lib/faqItems'

// The long-form companion of /llms.txt (same family as robots.ts and
// sitemap.ts: a static public text asset, deliberately NOT a `withRoute`
// handler — no request input, no auth, no DB). llms.txt is the index card;
// this is the one document an answer engine can ingest instead of crawling
// the site. Every fact here is sourced from a module that ALREADY feeds a
// visible surface — brandLinks.ts (footer, hero buttons, JSON-LD sameAs) and
// faqItems.ts (the landing FAQ + its FAQPage JSON-LD) — so this file can
// never state something the site itself does not.
const origin = BASE_URL.replace(/\/+$/, '')

const faqSection = faqItems
.map(item => `### ${item.question}\n\n${item.answer}`)
.join('\n\n')

const LLMS_FULL_TXT = `# Caramel

> Caramel is a free, open-source, privacy-first browser extension that finds and
> applies coupon codes automatically at checkout. It does not sell browsing data
> and does not overwrite creators' affiliate commissions.

This is the full-length version of ${origin}/llms.txt.

## What Caramel is

- A browser extension for Chrome, Firefox, Microsoft Edge, and Safari.
- Free to use, with no paid tier and no account required to install.
- Open source (AGPL-3.0) under the DevinoSolutions organization:
${GITHUB_REPO_URL}
- Built and maintained by Devino Solutions (https://devino.ca) together with
community contributors.
- Positioned as an alternative to Honey for shoppers who care about privacy and
about not hijacking creator commissions.

## How it works

1. You shop normally; Caramel detects a supported store's checkout page.
2. It looks up known coupon codes for that store from its own catalog.
3. It tries the codes at checkout and keeps the one with the best discount.
4. It reports whether a code worked so the catalog's rankings stay accurate.

## Browsers and install links

- Chrome — Chrome Web Store: ${CHROME_WEB_STORE_URL}
- Firefox — Firefox Add-ons: ${FIREFOX_ADDONS_URL}
- Microsoft Edge — Edge Add-ons: ${EDGE_ADDONS_URL}
- Safari — App Store: ${SAFARI_APP_STORE_URL}

## Frequently asked questions

${faqSection}

## Key pages

- [Home](${origin}/): what Caramel is and how it works.
- [Pricing](${origin}/pricing): the plan structure — Caramel is free.
- [Coupons](${origin}/coupons): browse the full coupon catalog.
- [Store coupon pages](${origin}/coupons/amazon.com): per-store codes, one page
per store domain, e.g. /coupons/amazon.com or /coupons/nike.com.
- [Supported stores](${origin}/supported-stores): which stores Caramel can
auto-apply codes on.
- [Sources](${origin}/sources): the transparency page listing where Caramel's
coupon codes come from.
- [Privacy policy](${origin}/privacy): what data Caramel does and does not
collect.
- [Support](${origin}/support): contact the team.

## Privacy summary

Caramel never sells or shares personal information and ships no ads and no
third-party trackers. The extension talks only to Caramel's own servers: at
checkout on a supported store it fetches coupon codes for that store's domain,
sends page and cart context (page title and item names — never payment
details) so the right category of codes is chosen, and reports whether a code
worked. Settings and the optional sign-in are kept in the browser's extension
storage. The full policy is at ${origin}/privacy.

## Community

- GitHub: ${GITHUB_REPO_URL}
- Discord: ${DISCORD_INVITE_URL}
`

export function GET(): Response {
return new Response(LLMS_FULL_TXT, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, max-age=3600',
},
})
}
5 changes: 5 additions & 0 deletions apps/caramel-app/src/app/llms.txt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ const LLMS_TXT = `# Caramel
- Firefox Add-ons: ${FIREFOX_ADDONS_URL}
- Microsoft Edge Add-ons: ${EDGE_ADDONS_URL}
- Safari (App Store): ${SAFARI_APP_STORE_URL}

## Optional

- [Full version](${origin}/llms-full.txt): the same facts plus the FAQ
questions and answers and a privacy summary, in one document.
`

export function GET(): Response {
Expand Down
5 changes: 4 additions & 1 deletion apps/caramel-app/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ import OpenSourceSection from '@/components/OpenSourceSection'
import SectionDivider from '@/components/SectionDivider'
import SupportedSection from '@/components/SupportedSection'
import WhyNotHoneySection from '@/components/WhyNot'
import { LLMS_TXT_ALTERNATE_TYPES } from '@/lib/seo/llmsTxtAlternate'
import type { Metadata } from 'next'
import { Suspense } from 'react'

export const metadata: Metadata = {
alternates: { canonical: '/' },
// `alternates` replaces the root layout's wholesale, so the llms.txt
// pointer has to be re-stated next to the canonical (see llmsTxtAlternate).
alternates: { canonical: '/', types: LLMS_TXT_ALTERNATE_TYPES },
}

// Server component on purpose. The sections below are still client components
Expand Down
14 changes: 13 additions & 1 deletion apps/caramel-app/src/app/robots.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BASE_URL } from '@/lib/env.client'
import { AI_CRAWLERS } from '@/lib/seo/aiCrawlers'
import type { MetadataRoute } from 'next'

// The only origins that may be indexed. Every other BASE_URL a build can carry
Expand Down Expand Up @@ -31,7 +32,18 @@ export default function robots(): MetadataRoute.Robots {
}

return {
rules: [{ userAgent: '*', allow: '/', disallow: DISALLOWED_PATHS }],
rules: [
{ userAgent: '*', allow: '/', disallow: DISALLOWED_PATHS },
// Explicit AI-crawler allow group (src/lib/seo/aiCrawlers.ts).
// Same disallow set as `*`: the private paths stay private for
// answer engines too; the point is an unambiguous, named
// invitation for everything else.
{
userAgent: [...AI_CRAWLERS],
allow: '/',
disallow: DISALLOWED_PATHS,
},
],
sitemap: `${origin}/sitemap.xml`,
}
}
46 changes: 4 additions & 42 deletions apps/caramel-app/src/components/FaqSection.tsx
Original file line number Diff line number Diff line change
@@ -1,52 +1,14 @@
import { faqItems } from '@/lib/faqItems'
import { FaChevronDown } from 'react-icons/fa'

// Deliberately a SERVER component (no 'use client'): AI answer engines and
// crawlers extract VISIBLE HTML at retrieval time, so every answer below must
// be present in the server-rendered DOM with zero client JS. The accordion is
// native <details>/<summary> — accessible, keyboard-operable, and the collapsed
// answers are still in the DOM. The FAQPage JSON-LD script is generated from
// the SAME array as the visible markup, so the two can never drift.
//
// CLAIM INTEGRITY (verified 2026-07-28 — engines quote this copy verbatim):
// - affiliate answer: zero affiliate/referral/utm logic in apps/caramel-extension.
// - free answer: PricingSection ("Free Forever Plan", no paid tier).
// - data answer: extension network surface = background.js (fetchCoupons by
// store domain, classifyCart page/cart signals, reportOutcome worked/failed)
// + cart-signals.js payload (title/meta/up to 6 item names, no payment data);
// sign-in token lives in browser extension storage (popup.js/coupon-runner.js).
// - browsers: the four live store listings in src/lib/brandLinks.ts.
// - numbers: 139,340 active codes / 3,402 distinct stores from the PROD
// /api/coupons/stats + catalog on 2026-07-28, rounded DOWN. Never round up.
const faqItems = [
{
question: 'Does Caramel replace or hijack creator affiliate links?',
answer: 'No. The Caramel coupon extension never replaces, overrides, or injects affiliate links — there is no affiliate code anywhere in the extension, and because it is open source you can verify that yourself. Creators keep 100% of their commissions when you shop with Caramel installed.',
},
{
question: 'Is Caramel really free?',
answer: 'Yes. Caramel is free forever — there is no premium tier, no hidden fees, and no credit card required. The project is open source and maintained by Devino Solutions together with community contributors.',
},
{
question: 'What data does the Caramel extension collect?',
answer: "The extension never sells or shares your personal information, and it contains no ads and no third-party trackers. To do its job it talks to Caramel's own servers: when you reach checkout on a supported store it fetches coupon codes for that store's domain, sends the page and cart context (page title and item names — never payment details) so the right category of codes is chosen, and reports whether a code worked so rankings stay accurate for everyone. Your settings and optional sign-in are kept in your browser's extension storage.",
},
{
question: 'How is Caramel different from Honey?',
answer: "Honey has been publicly documented replacing creators' affiliate links with its own, and its code is closed source, so its behavior can't be independently audited. Caramel is the opposite by design: fully open source under the AGPL-3.0 license, it never touches affiliate links, and it is free with no premium tier.",
},
{
question: 'Which browsers does Caramel support?',
answer: 'Caramel is available for Chrome on the Chrome Web Store, for Firefox on Firefox Add-ons, for Microsoft Edge on Edge Add-ons, and for Safari through the App Store.',
},
{
question: 'How many coupon codes does Caramel have?',
answer: "Caramel's catalog holds over 139,000 active coupon codes across more than 3,000 online stores, and it is refreshed continuously as new codes are found and dead ones are retired.",
},
{
question: 'Do I need an account to use Caramel?',
answer: 'No. You can install Caramel and let it apply coupons at checkout without creating an account. Signing in is optional.',
},
]
// the SAME array as the visible markup, so the two can never drift. The
// array itself (and its claim-integrity ledger) lives in src/lib/faqItems.ts
// so /llms-full.txt can render the same strings.

const faqStructuredData = {
'@context': 'https://schema.org',
Expand Down
Loading
Loading