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
2 changes: 1 addition & 1 deletion docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Every guide opens with the reader's main question as its first H2 and an answer

To retire a guide, delete its `PAGES` entry, its `CLUSTERS` and `LINK_PHRASES` entries, and its `public/<slug>/` folder (the generator never deletes), add its redirects to `vercel.json`, and repoint any link to it. Two guides that answer the same search should become one page, not two thin ones.

The site origin, product name, brand line, organization name and the organization's `sameAs` profiles live in [`src/config/site.json`](../src/config/site.json). List a profile in `sameAs` only after checking that its live page spells the name "DebateOwl"; on 29 October 2026 the only two listings (SaaSHub and Uneed) still said "DebateLab", so the list is empty and the Organization node leaves `sameAs` out. The app's head tags, the prerender and sitemap scripts, and the guide generator all read it, and guide copy names the product with a `{product}` token and the Pro price with `{pro_price}`. The number of prep modes comes from the tool lists in [`tool-links.tsx`](../src/components/features/tools/tool-links.tsx): the app's copy reads `PREP_MODE_COUNT` and `UTILITY_COUNT`, and guide copy uses `{mode_count}`, `{mode_names}` and `{utility_names}`. The trial length and Pro price come from `pricing` in the same file: i18next passes `{{trialDays}}`, `{{proMonthly}}`, `{{product}}`, `{{brand}}` and `{{host}}` to every catalog string, so the English, Spanish and Hungarian pages state the same facts. Do not hardcode those strings in new code or copy. App strings name the product with `{{product}}`, `{{brand}}` and `{{host}}`, which [`src/i18n.ts`](../src/i18n.ts) fills from `site.json` for every key.
The site origin, product name, brand line, organization name and the organization's `sameAs` profiles live in [`src/config/site.json`](../src/config/site.json). List a profile in `sameAs` only after checking that its live page spells the name "DebateOwl"; the home page's Organization and SoftwareApplication nodes both carry the list. As of 3 November 2026 it holds the GitHub repository, SaaSHub (renamed from its old DebateLab slug, which now 404s) and Artificial Intelligence Tools List (AITL). Uneed still lists the app under its old DebateLab slug, so it stays out until that changes. The app's head tags, the prerender and sitemap scripts, and the guide generator all read it, and guide copy names the product with a `{product}` token and the Pro price with `{pro_price}`. The number of prep modes comes from the tool lists in [`tool-links.tsx`](../src/components/features/tools/tool-links.tsx): the app's copy reads `PREP_MODE_COUNT` and `UTILITY_COUNT`, and guide copy uses `{mode_count}`, `{mode_names}` and `{utility_names}`. The trial length and Pro price come from `pricing` in the same file: i18next passes `{{trialDays}}`, `{{proMonthly}}`, `{{product}}`, `{{brand}}` and `{{host}}` to every catalog string, so the English, Spanish and Hungarian pages state the same facts. Do not hardcode those strings in new code or copy. App strings name the product with `{{product}}`, `{{brand}}` and `{{host}}`, which [`src/i18n.ts`](../src/i18n.ts) fills from `site.json` for every key.

After a production deploy, and only with Adam's approval, `npm run seo:indexnow` sends every URL in the sitemap to IndexNow in one request to `api.indexnow.org`, which shares it with Bing, Yandex, Seznam, Naver and the other engines that take part (not Google). It reads `dist/sitemap.xml` when a build is present and the live sitemap otherwise. `-- --only /tools /` sends just those pages, and `-- --dry-run` prints the request without sending it. The key file is `public/<key>.txt`. It is not wired into CI.

Expand Down
1 change: 1 addition & 0 deletions public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ DebateOwl runs at https://debateowl.com/. It is a separate product from debatela
- Trial and free tools: The DebateOwl app has no free plan (3-day free trial, then $5/month); the separate tools at /tools are free with no account.
- Pro adds expanded AI prep access, all 10 models (including Claude Opus and Sonnet, GPT-6.1 Sol and Gemini), Active Grounding research and priority support.
- Privacy: cases, sources, practice rounds and history stay behind sign-in and are not indexed.
- Listed elsewhere: https://github.com/Programcode/DebateOwl, https://www.saashub.com/debateowl, https://www.artificialintelligencetoolslist.com/ai-tools/debateowl

## Main pages

Expand Down
1 change: 1 addition & 0 deletions scripts/generate_seo_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,7 @@ def llms_txt():
- Trial and free tools: {FREE_PLAN}
- Pro adds expanded AI prep access, all 10 models (including Claude Opus and Sonnet, GPT-6.1 Sol and Gemini), Active Grounding research and priority support.
- Privacy: cases, sources, practice rounds and history stay behind sign-in and are not indexed.
- Listed elsewhere: {", ".join(ORGANIZATION["sameAs"])}

## Main pages

Expand Down
2 changes: 1 addition & 1 deletion scripts/prerender.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { createServer } from 'vite';
const DIST = 'dist';
const SITE_ORIGIN = JSON.parse(readFileSync('src/config/site.json', 'utf8')).origin;
// The landing page's lastmod is the last commit that changed these.
const LANDING_SOURCES = ['src/pages/LandingPage.tsx', 'src/components/landing', 'src/lib/seo/pages.ts'];
const LANDING_SOURCES = ['src/pages/LandingPage.tsx', 'src/components/landing', 'src/lib/seo/pages.ts', 'src/config/site.json'];

const escapeAttribute = (value) => value
.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
Expand Down
6 changes: 5 additions & 1 deletion src/config/site.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
},
"description": "DebateOwl (formerly DebateLab at debate.pi-stack.org) is an AI debate prep and practice app for students and competitive debaters.",
"disambiguatingDescription": "DebateOwl is an AI debate prep and practice app for students, not a sports or computer-vision product.",
"sameAs": ["https://github.com/Programcode/DebateOwl"],
"sameAs": [
"https://github.com/Programcode/DebateOwl",
"https://www.saashub.com/debateowl",
"https://www.artificialintelligencetoolslist.com/ai-tools/debateowl"
],
"operator": {
"name": "Ádám Gáborjáni",
"country": "Hungary",
Expand Down
4 changes: 2 additions & 2 deletions src/lib/seo/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export const ORGANIZATION_ID = `${SITE_ORIGIN}/#organization`;
* see one name for one entity. Its founder is the person who runs it; Pi-Stack
* is only the name he publishes under, not a company, so it appears as his URL
* and never as a parent organization or legal name. A sameAs profile is the same
* entity elsewhere: site.json lists only profiles that spell the name "DebateOwl"
* and that DebateOwl itself runs; with none, the node has no sameAs. /press, the
* entity elsewhere: site.json lists only pages about DebateOwl that spell the name
* "DebateOwl" (its repository and directory listings); with none, the node has no sameAs. /press, the
* fact sheet, is its subjectOf.
*/
export function organizationNode() {
Expand Down
4 changes: 3 additions & 1 deletion src/prerender/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ function landingStructuredData(dateModified: string | undefined) {
image: `${SITE_ORIGIN}/hero-visual.jpg?v=2`,
description: SOFTWARE_DESCRIPTION,
disambiguatingDescription: site.organization.disambiguatingDescription,
// The same listings as the Organization: each is a page about the app itself.
...(site.organization.sameAs.length ? { sameAs: site.organization.sameAs } : {}),
inLanguage: ['en', 'es', 'hu'],
featureList: [...APP_TOOLS.map((tool) => i18n.t(`features.${tool.key}`)), EXPORT_FEATURE],
// One paid offer. Google reads an Offer priced 0 as an app available without
Expand Down Expand Up @@ -199,7 +201,7 @@ function legalStructuredData(route: '/terms' | '/privacy', page: 'termsPage' | '
}

// The day each page's copy last changed; move it when its text does.
const PAGE_MODIFIED = { '/about': '2026-11-02', '/contact': '2026-10-28', '/press': '2026-11-02' };
const PAGE_MODIFIED = { '/about': '2026-11-03', '/contact': '2026-10-28', '/press': '2026-11-03' };

/**
* /about as an AboutPage and /press as a WebPage, each about the Organization,
Expand Down
Loading