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: 2 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ The landing page and the free tools also exist in Hungarian and Spanish, each at

[`scripts/sitemap.mjs`](../scripts/sitemap.mjs) then writes `dist/sitemap.xml` from every page in `dist/` that names itself as canonical and is not `noindex`. A page with `hreflang` links in its head is listed with the same alternates as `xhtml:link` entries. Each `<lastmod>` is the `dateModified` the page declares in its JSON-LD: the guide pages' `CONTENT_DATE` in [`scripts/generate_seo_content.py`](../scripts/generate_seo_content.py), the legal pages' "last updated" dates, the tool pages' `TOOL_PAGES_MODIFIED`, and the landing page's last source commit. A page without a date is listed without `<lastmod>`.

[`scripts/llms-full.mjs`](../scripts/llms-full.mjs) then writes `dist/llms-full.txt`: `llms.txt` followed by the visible text of `/`, `/about`, `/best-ai-for-debate` and `/press`, read from their built HTML. It is a build output, not a file in `public/`.

[`scripts/check-site.mjs`](../scripts/check-site.mjs) runs next and fails the build when an internal link, a `#fragment`, a sitemap URL or an `llms.txt` URL does not resolve (it applies the `vercel.json` redirects and rewrites), when JSON-LD does not parse or carries HTML entities, when a FAQPage question is not visible on its page, when the home page's entity graph has an unresolved `@id`, a `WebSite` or `SoftwareApplication` not named for the product, or no `Organization.sameAs`, when a page's `publisher` or `about` names an `@id` the home page does not define, when an indexed page other than `/terms`, `/privacy`, and `/expocit/sources` lacks its question H2 (`#short-answer`) as the first H2 (a page whose H1 is already that question may instead put the answer in a `#short-answer` paragraph before the first H2), when a page's first heading is not its H1 or a heading skips a level (H1 to H3), when an image has no alt text, a button has no name or a form field has no label, or when a page's `hreflang` links do not resolve, leave out the page itself or `x-default`, disagree with `<html lang>`, or differ from the set on a page they name.

[`scripts/check-links.mjs`](../scripts/check-links.mjs) runs after it and fails the build when a sitemap page other than `/terms`, `/privacy` and `/expocit/sources` has fewer than 3 inbound links from other sitemap pages, or when the home page links to fewer than 15 distinct sitemap pages. `node scripts/check-links.mjs --report` prints every count after a build. The home page reaches the guides and tools through [`GuideHub.tsx`](../src/components/landing/GuideHub.tsx).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"docs:check": "node scripts/check-docs.mjs",
"backend:status": "node scripts/backend-status.mjs",
"ci:failures": "node scripts/ci-failures.mjs",
"build": "vite build && node scripts/prerender.mjs && node scripts/sitemap.mjs && node scripts/check-site.mjs && node scripts/check-links.mjs && node scripts/check-build-boundaries.mjs",
"build": "vite build && node scripts/prerender.mjs && node scripts/sitemap.mjs && node scripts/llms-full.mjs && node scripts/check-site.mjs && node scripts/check-links.mjs && node scripts/check-build-boundaries.mjs",
"build:dev": "vite build --mode development",
"lint": "eslint . && npm run lint:repo",
"lint:repo": "knip --no-config-hints && node scripts/check-repo-rules.mjs",
Expand Down
4 changes: 3 additions & 1 deletion public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> DebateOwl is an AI debate prep and practice app for students and competitive debaters that builds structured cases, finds the weak points in arguments, researches evidence with citations and runs timed practice rounds against an AI opponent.

DebateOwl runs at https://debateowl.com/. It is a separate product from debatelab.ai. DebateOwl was called DebateLab (debate.pi-stack.org) until 27 October 2026.
DebateOwl runs at https://debateowl.com/. It is a separate product from debatelab.ai. It is not Owl AI (owl.ai), a computer-vision platform for sports. DebateOwl was called DebateLab (debate.pi-stack.org) until 27 October 2026.

## Key facts (as of October 2026)

Expand All @@ -23,6 +23,8 @@ DebateOwl runs at https://debateowl.com/. It is a separate product from debatela
- [Pricing](https://debateowl.com/debateowl-pricing): trial, Pro and what each includes
- [About](https://debateowl.com/about): what DebateOwl is, who makes it and its former name
- [Contact](https://debateowl.com/contact): email hello@debateowl.com for support and feedback, privacy@debateowl.com for privacy and data requests
- [Press kit and facts](https://debateowl.com/press): a one-sentence definition, former name, maker, languages, pricing, logo downloads and what DebateOwl is not
- [Full text](https://debateowl.com/llms-full.txt): this file plus the plain text of the home page, About, Which AI is best for debate? and the press kit
- [Sitemap](https://debateowl.com/sitemap.xml): every public page

## Free tools
Expand Down
4 changes: 3 additions & 1 deletion scripts/generate_seo_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -841,7 +841,7 @@ def llms_txt():

> {DEFINITION}

{BRAND} runs at {BASE}/. It is a separate product from debatelab.ai. {BRAND} was called {FORMERLY["name"]} ({FORMERLY["host"]}) until {long_date_dmy(FORMERLY["until"])}.
{BRAND} runs at {BASE}/. It is a separate product from debatelab.ai. It is not Owl AI (owl.ai), a computer-vision platform for sports. {BRAND} was called {FORMERLY["name"]} ({FORMERLY["host"]}) until {long_date_dmy(FORMERLY["until"])}.

## Key facts (as of {FACTS_AS_OF})

Expand All @@ -862,6 +862,8 @@ def llms_txt():
- [Pricing]({page_url("debateowl-pricing")}): trial, Pro and what each includes
- [About]({BASE}/about): what {PRODUCT} is, who makes it and its former name
- [Contact]({BASE}/contact): email {SITE["contactEmail"]} for support and feedback, {SITE["privacyEmail"]} for privacy and data requests
- [Press kit and facts]({BASE}/press): a one-sentence definition, former name, maker, languages, pricing, logo downloads and what {PRODUCT} is not
- [Full text]({BASE}/llms-full.txt): this file plus the plain text of the home page, About, Which AI is best for debate? and the press kit
- [Sitemap]({BASE}/sitemap.xml): every public page

## Free tools
Expand Down
49 changes: 49 additions & 0 deletions scripts/llms-full.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Runs after scripts/sitemap.mjs. Writes dist/llms-full.txt: llms.txt, then the
// plain text of the pages an answer engine needs to say what DebateOwl is, read
// from their built HTML so the text is what a visitor sees.
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

const DIST = 'dist';
const SITE_ORIGIN = JSON.parse(readFileSync('src/config/site.json', 'utf8')).origin;
const PAGES = ['/', '/about', '/best-ai-for-debate', '/press'];

const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
const decode = (text) => text.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (entity, name) => {
if (name[0] === '#') return String.fromCodePoint(name[1].toLowerCase() === 'x' ? parseInt(name.slice(2), 16) : Number(name.slice(1)));
return ENTITIES[name.toLowerCase()] ?? entity;
});

/** The page's <main> as Markdown-ish text: headings two levels below the page's own section, list items with a dash. */
function plainText(html) {
const main = html.match(/<main\b[\s\S]*<\/main>/)?.[0];
if (!main) throw new Error('no <main> element');
const text = main
.replace(/<(script|style|svg|noscript)\b[\s\S]*?<\/\1>/g, '')
.replace(/<\/dt>\s*<dd\b[^>]*>/g, ': ')
.replace(/<h([1-6])\b[^>]*>/g, (_, level) => `\n\n${'#'.repeat(Number(level) + 2)} `)
.replace(/<\/h[1-6]>/g, '\n\n')
.replace(/<(li|dt|summary)\b[^>]*>/g, '\n- ')
.replace(/<\/?(p|div|section|article|header|ul|ol|dl|dd|details|table|tr|br|nav)\b[^>]*>/g, '\n')
.replace(/<\/t[dh]>/g, ' | ')
.replace(/<[^>]+>/g, '');
return decode(text)
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter((line, index, lines) => line || lines[index - 1])
.join('\n')
.trim();
}

const sections = PAGES.map((route) => {
const html = readFileSync(join(DIST, route === '/' ? 'index.html' : `${route.slice(1)}/index.html`), 'utf8');
const title = decode(html.match(/<title>([^<]*)<\/title>/)?.[1] ?? route);
try {
return `## ${title}\n\nSource: ${SITE_ORIGIN}${route}\n\n${plainText(html)}`;
} catch (error) {
throw new Error(`llms-full: ${route}: ${error.message}`);
}
});
const llms = readFileSync(join(DIST, 'llms.txt'), 'utf8').trim();
writeFileSync(join(DIST, 'llms-full.txt'), `${llms}\n\n# Page text\n\n${sections.join('\n\n')}\n`);
console.log(`llms-full: llms.txt and ${PAGES.length} pages`);
3 changes: 3 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const TermsPage = prerenderedPage("/terms");
const PrivacyPage = prerenderedPage("/privacy");
const AboutPage = prerenderedPage("/about");
const ContactPage = prerenderedPage("/contact");
const PressPage = prerenderedPage("/press");
// The Hungarian and Spanish addresses in src/config/localized-routes.json.
const LOCALIZED_PAGES = LOCALIZED_PATHS.map((entry) => ({ ...entry, Page: prerenderedModule(localizedRouteLoaders[entry.path]) }));
const HomePage = lazyChunk(routeLoaders["/app"]);
Expand Down Expand Up @@ -176,6 +177,7 @@ const ROUTE_FALLBACKS: Record<string, RouteFallback> = {
"/privacy": { variant: "public", label: "Privacy", labelKey: "footer.privacy" },
"/about": { variant: "public", label: "About", labelKey: "footer.about" },
"/contact": { variant: "public", label: "Contact", labelKey: "footer.contact" },
"/press": { variant: "public", label: "Press kit", labelKey: "footer.press" },
"/expocit/sources": { variant: "public", label: "Sources", labelKey: "crossfire.sources" },
"/login": { variant: "public", label: "Sign in", labelKey: "login.title" },
"/auth/callback": { variant: "public", label: "Sign in", labelKey: "login.title" },
Expand Down Expand Up @@ -477,6 +479,7 @@ function AppRoutes() {
<Route path="/privacy" element={<RouteContent><PrivacyPage /></RouteContent>} />
<Route path="/about" element={<RouteContent><AboutPage /></RouteContent>} />
<Route path="/contact" element={<RouteContent><ContactPage /></RouteContent>} />
<Route path="/press" element={<RouteContent><PressPage /></RouteContent>} />
<Route path="/expocit/sources" element={<RouteContent><SourcesPage /></RouteContent>} />
<Route path="/login" element={<RouteContent><LoginPage /></RouteContent>} />
<Route path={AUTH_CALLBACK_PATH} element={<RouteContent><AuthCallbackPage /></RouteContent>} />
Expand Down
5 changes: 4 additions & 1 deletion src/components/landing/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,16 @@ const Footer = () => {
<Link to="/privacy" className="inline-flex min-h-6 items-center text-muted-foreground hover:text-foreground transition-colors underline-offset-4 hover:underline pointer-coarse:min-h-11 pointer-coarse:min-w-11 pointer-coarse:justify-center">
{t('footer.privacy')}
</Link>
{/* English pages: there is no Hungarian or Spanish About or Contact address. */}
{/* English pages: there is no Hungarian or Spanish About, Contact or Press address. */}
<Link to="/about" className="inline-flex min-h-6 items-center text-muted-foreground hover:text-foreground transition-colors underline-offset-4 hover:underline pointer-coarse:min-h-11 pointer-coarse:min-w-11 pointer-coarse:justify-center">
{t('footer.about')}
</Link>
<Link to="/contact" className="inline-flex min-h-6 items-center text-muted-foreground hover:text-foreground transition-colors underline-offset-4 hover:underline pointer-coarse:min-h-11 pointer-coarse:min-w-11 pointer-coarse:justify-center">
{t('footer.contact')}
</Link>
<Link to="/press" className="inline-flex min-h-6 items-center text-muted-foreground hover:text-foreground transition-colors underline-offset-4 hover:underline pointer-coarse:min-h-11 pointer-coarse:min-w-11 pointer-coarse:justify-center">
{t('footer.press')}
</Link>
</nav>

<div className="text-center text-sm text-muted-foreground md:text-right">
Expand Down
3 changes: 2 additions & 1 deletion src/config/site.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
"height": 512
},
"description": "DebateOwl (formerly DebateLab at debate.pi-stack.org) is an AI debate prep and practice app for students and competitive debaters.",
"sameAs": [],
"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"],
"parent": {
"name": "Pi-Stack",
"url": "https://pi-stack.org/"
Expand Down
1 change: 1 addition & 0 deletions src/lib/route-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const routeLoaders = {
"/privacy": cacheLoader(() => import("@/pages/PrivacyPage")),
"/about": cacheLoader(() => import("@/pages/AboutPage")),
"/contact": cacheLoader(() => import("@/pages/ContactPage")),
"/press": cacheLoader(() => import("@/pages/PressPage")),
"/expocit/sources": cacheLoader(() => import("@/pages/SourcesPage")),
"/login": cacheLoader(() => import("@/pages/LoginPage")),
"/auth/callback": cacheLoader(() => import("@/pages/AuthCallbackPage")),
Expand Down
9 changes: 7 additions & 2 deletions src/lib/seo/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ export const ORGANIZATION_ID = `${SITE_ORIGIN}/#organization`;
* The Organization behind the site, named for the product so search engines
* see one name for one entity. Pi-Stack, which builds it, is its parent, not a
* sameAs: a sameAs profile is the same entity elsewhere. site.json lists only
* profiles that spell the name "DebateOwl"; with none, the node has no sameAs.
* profiles that spell the name "DebateOwl" and that DebateOwl itself runs; with
* none, the node has no sameAs. /press, the fact sheet, is its subjectOf.
*/
export function organizationNode() {
const { name, logo, description, sameAs, parent } = site.organization;
const { name, logo, description, disambiguatingDescription, sameAs, parent } = site.organization;
return {
'@type': 'Organization',
'@id': ORGANIZATION_ID,
Expand All @@ -25,10 +26,12 @@ export function organizationNode() {
url: `${SITE_ORIGIN}/`,
logo: { '@type': 'ImageObject', url: `${SITE_ORIGIN}${logo.path}`, width: logo.width, height: logo.height },
description,
disambiguatingDescription,
email: site.contactEmail,
contactPoint: { '@type': 'ContactPoint', contactType: 'customer support', email: site.contactEmail, availableLanguage: ['en', 'hu', 'es'] },
parentOrganization: { '@type': 'Organization', '@id': `${parent.url}#organization`, name: parent.name, url: parent.url },
...(sameAs.length ? { sameAs } : {}),
subjectOf: { '@type': 'WebPage', '@id': `${SITE_ORIGIN}/press#webpage`, url: `${SITE_ORIGIN}/press`, name: PAGE_HEADS['/press'].title },
};
}
export const PRICING = site.pricing;
Expand Down Expand Up @@ -60,6 +63,7 @@ export const PAGE_HEADS = {
'/privacy': { ...englishHead('privacy'), canonicalPath: '/privacy' },
'/about': { ...englishHead('about'), canonicalPath: '/about' },
'/contact': { ...englishHead('contact'), canonicalPath: '/contact' },
'/press': { ...englishHead('press'), canonicalPath: '/press' },
'/expocit/sources': { ...englishHead('sources'), canonicalPath: '/expocit/sources' },
...TOOL_PAGE_HEADS,
'*': englishHead('notFound'),
Expand All @@ -74,6 +78,7 @@ export const PAGE_HEAD_IDS: Record<PrerenderedRoute, PageHeadId> = {
'/privacy': 'privacy',
'/about': 'about',
'/contact': 'contact',
'/press': 'press',
'/expocit/sources': 'sources',
...TOOL_PAGE_HEAD_IDS,
'*': 'notFound',
Expand Down
33 changes: 32 additions & 1 deletion src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,7 @@
"privacy": "Privacy",
"about": "About",
"contact": "Contact",
"press": "Press kit",
"copyright": "© 2026 Pi-Stack. All rights reserved.",
"formerly": "{{product}}, formerly DebateLab.",
"navLabel": "Footer",
Expand All @@ -896,7 +897,7 @@
},
"hero": {
"headline": "{{product}}: AI debate prep and practice",
"subheadline": "{{product}} helps you prepare cases and practice rounds with AI.",
"subheadline": "{{product}} is an AI debate prep and practice app: build cases, find weak points and run timed rounds.",
"trialBadge": "{{trialDays}}-day free trial",
"cta": "Start free trial",
"trustBullet1": "No credit card required",
Expand Down Expand Up @@ -1883,6 +1884,7 @@
"title": "About {{product}}",
"question": "What is {{product}}?",
"answer": "<home>{{product}}</home> is an AI debate prep and practice app for students and competitive debaters. It helps you write a case, find the weak points in an argument, research evidence with citations and practice timed rounds against an AI opponent.",
"notThis": "{{product}} is a debate app for students, not a sports or computer-vision product. The <press>press kit</press> has the short facts and the logo.",
"makerHeading": "Who makes it",
"maker": "{{product}} is built by a student developer and made by <pistack>Pi-Stack</pistack>.",
"nameHeading": "The name",
Expand All @@ -1893,6 +1895,31 @@
"languages": "English, Spanish and Hungarian.",
"linksHeading": "More about {{product}}"
},
"pressPage": {
"title": "{{product}} press kit and facts",
"checked": "Facts checked {{date}}.",
"definition": "<home>{{product}}</home> is an AI debate prep and practice app for students and competitive debaters that builds cases, finds the weak points in arguments, researches evidence with citations and runs timed practice rounds against an AI opponent.",
"factsHeading": "Facts",
"nameLabel": "Name",
"name": "{{product}}, also written {{alternateName}}",
"websiteLabel": "Website",
"formerlyLabel": "Former name",
"formerly": "{{name}}, at {{host}}, renamed on {{date}}",
"makerLabel": "Maker",
"maker": "Adam (<github>Programcode</github> on GitHub), a student developer in Hungary, published by <pistack>Pi-Stack</pistack>",
"launchedLabel": "Launched",
"launched": "2026, under the name {{name}}",
"languagesLabel": "Languages",
"pricingLabel": "Pricing",
"pricing": "A {{trialDays}}-day free trial with no credit card, then {{price}}/month + tax. There is no free plan; the <tools>free debate tools</tools> need no account.",
"contactLabel": "Press contact",
"logoHeading": "Logo",
"logoAlt": "The {{product}} owl logo",
"logoPng": "Download the logo as PNG (512 × 512)",
"logoSvg": "Download the logo as SVG",
"notHeading": "Not to be confused with",
"not": "{{product}} is not Owl AI (owl.ai), a real-time computer-vision platform for sports, and it is not debatelab.ai, a separate debate practice product. Before its rename, {{product}} was DebateLab at debate.pi-stack.org, not at debatelab.ai."
},
"contactPage": {
"title": "Contact {{product}}",
"question": "How do I contact {{product}}?",
Expand Down Expand Up @@ -2701,6 +2728,10 @@
"contact": {
"title": "Contact | {{brand}}",
"description": "How to email {{product}} for help with your account or billing, privacy and data requests, and feedback on the AI debate prep app."
},
"press": {
"title": "{{product}} press kit and facts | {{brand}}",
"description": "Facts about {{product}}, the AI debate prep and practice app for students: former name, maker, languages, pricing, logo downloads and what it is not."
}
},
"validation": {
Expand Down
Loading
Loading