Skip to content

Commit e3d8056

Browse files
authored
Merge pull request #293 from Programcode/t3/seo-task-c-press-brand
Add a /press fact sheet and say what DebateOwl is, and is not
2 parents 7de3024 + 099e713 commit e3d8056

17 files changed

Lines changed: 283 additions & 23 deletions

File tree

‎docs/operations.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ The landing page and the free tools also exist in Hungarian and Spanish, each at
6666

6767
[`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>`.
6868

69+
[`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/`.
70+
6971
[`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.
7072

7173
[`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).

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"docs:check": "node scripts/check-docs.mjs",
2424
"backend:status": "node scripts/backend-status.mjs",
2525
"ci:failures": "node scripts/ci-failures.mjs",
26-
"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",
26+
"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",
2727
"build:dev": "vite build --mode development",
2828
"lint": "eslint . && npm run lint:repo",
2929
"lint:repo": "knip --no-config-hints && node scripts/check-repo-rules.mjs",

‎public/llms.txt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
> 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.
44

5-
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.
5+
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.
66

77
## Key facts (as of October 2026)
88

@@ -23,6 +23,8 @@ DebateOwl runs at https://debateowl.com/. It is a separate product from debatela
2323
- [Pricing](https://debateowl.com/debateowl-pricing): trial, Pro and what each includes
2424
- [About](https://debateowl.com/about): what DebateOwl is, who makes it and its former name
2525
- [Contact](https://debateowl.com/contact): email hello@debateowl.com for support and feedback, privacy@debateowl.com for privacy and data requests
26+
- [Press kit and facts](https://debateowl.com/press): a one-sentence definition, former name, maker, languages, pricing, logo downloads and what DebateOwl is not
27+
- [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
2628
- [Sitemap](https://debateowl.com/sitemap.xml): every public page
2729

2830
## Free tools

‎scripts/generate_seo_content.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -843,7 +843,7 @@ def llms_txt():
843843
844844
> {DEFINITION}
845845
846-
{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"])}.
846+
{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"])}.
847847
848848
## Key facts (as of {FACTS_AS_OF})
849849
@@ -864,6 +864,8 @@ def llms_txt():
864864
- [Pricing]({page_url("debateowl-pricing")}): trial, Pro and what each includes
865865
- [About]({BASE}/about): what {PRODUCT} is, who makes it and its former name
866866
- [Contact]({BASE}/contact): email {SITE["contactEmail"]} for support and feedback, {SITE["privacyEmail"]} for privacy and data requests
867+
- [Press kit and facts]({BASE}/press): a one-sentence definition, former name, maker, languages, pricing, logo downloads and what {PRODUCT} is not
868+
- [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
867869
- [Sitemap]({BASE}/sitemap.xml): every public page
868870
869871
## Free tools

‎scripts/llms-full.mjs‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Runs after scripts/sitemap.mjs. Writes dist/llms-full.txt: llms.txt, then the
2+
// plain text of the pages an answer engine needs to say what DebateOwl is, read
3+
// from their built HTML so the text is what a visitor sees.
4+
import { readFileSync, writeFileSync } from 'node:fs';
5+
import { join } from 'node:path';
6+
7+
const DIST = 'dist';
8+
const SITE_ORIGIN = JSON.parse(readFileSync('src/config/site.json', 'utf8')).origin;
9+
const PAGES = ['/', '/about', '/best-ai-for-debate', '/press'];
10+
11+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
12+
const decode = (text) => text.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (entity, name) => {
13+
if (name[0] === '#') return String.fromCodePoint(name[1].toLowerCase() === 'x' ? parseInt(name.slice(2), 16) : Number(name.slice(1)));
14+
return ENTITIES[name.toLowerCase()] ?? entity;
15+
});
16+
17+
/** The page's <main> as Markdown-ish text: headings two levels below the page's own section, list items with a dash. */
18+
function plainText(html) {
19+
const main = html.match(/<main\b[\s\S]*<\/main>/)?.[0];
20+
if (!main) throw new Error('no <main> element');
21+
const text = main
22+
.replace(/<(script|style|svg|noscript)\b[\s\S]*?<\/\1>/g, '')
23+
.replace(/<\/dt>\s*<dd\b[^>]*>/g, ': ')
24+
.replace(/<h([1-6])\b[^>]*>/g, (_, level) => `\n\n${'#'.repeat(Number(level) + 2)} `)
25+
.replace(/<\/h[1-6]>/g, '\n\n')
26+
.replace(/<(li|dt|summary)\b[^>]*>/g, '\n- ')
27+
.replace(/<\/?(p|div|section|article|header|ul|ol|dl|dd|details|table|tr|br|nav)\b[^>]*>/g, '\n')
28+
.replace(/<\/t[dh]>/g, ' | ')
29+
.replace(/<[^>]+>/g, '');
30+
return decode(text)
31+
.split('\n')
32+
.map((line) => line.replace(/\s+/g, ' ').trim())
33+
.filter((line, index, lines) => line || lines[index - 1])
34+
.join('\n')
35+
.trim();
36+
}
37+
38+
const sections = PAGES.map((route) => {
39+
const html = readFileSync(join(DIST, route === '/' ? 'index.html' : `${route.slice(1)}/index.html`), 'utf8');
40+
const title = decode(html.match(/<title>([^<]*)<\/title>/)?.[1] ?? route);
41+
try {
42+
return `## ${title}\n\nSource: ${SITE_ORIGIN}${route}\n\n${plainText(html)}`;
43+
} catch (error) {
44+
throw new Error(`llms-full: ${route}: ${error.message}`);
45+
}
46+
});
47+
const llms = readFileSync(join(DIST, 'llms.txt'), 'utf8').trim();
48+
writeFileSync(join(DIST, 'llms-full.txt'), `${llms}\n\n# Page text\n\n${sections.join('\n\n')}\n`);
49+
console.log(`llms-full: llms.txt and ${PAGES.length} pages`);

‎src/App.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const TermsPage = prerenderedPage("/terms");
5757
const PrivacyPage = prerenderedPage("/privacy");
5858
const AboutPage = prerenderedPage("/about");
5959
const ContactPage = prerenderedPage("/contact");
60+
const PressPage = prerenderedPage("/press");
6061
// The Hungarian and Spanish addresses in src/config/localized-routes.json.
6162
const LOCALIZED_PAGES = LOCALIZED_PATHS.map((entry) => ({ ...entry, Page: prerenderedModule(localizedRouteLoaders[entry.path]) }));
6263
const HomePage = lazyChunk(routeLoaders["/app"]);
@@ -176,6 +177,7 @@ const ROUTE_FALLBACKS: Record<string, RouteFallback> = {
176177
"/privacy": { variant: "public", label: "Privacy", labelKey: "footer.privacy" },
177178
"/about": { variant: "public", label: "About", labelKey: "footer.about" },
178179
"/contact": { variant: "public", label: "Contact", labelKey: "footer.contact" },
180+
"/press": { variant: "public", label: "Press kit", labelKey: "footer.press" },
179181
"/expocit/sources": { variant: "public", label: "Sources", labelKey: "crossfire.sources" },
180182
"/login": { variant: "public", label: "Sign in", labelKey: "login.title" },
181183
"/auth/callback": { variant: "public", label: "Sign in", labelKey: "login.title" },
@@ -477,6 +479,7 @@ function AppRoutes() {
477479
<Route path="/privacy" element={<RouteContent><PrivacyPage /></RouteContent>} />
478480
<Route path="/about" element={<RouteContent><AboutPage /></RouteContent>} />
479481
<Route path="/contact" element={<RouteContent><ContactPage /></RouteContent>} />
482+
<Route path="/press" element={<RouteContent><PressPage /></RouteContent>} />
480483
<Route path="/expocit/sources" element={<RouteContent><SourcesPage /></RouteContent>} />
481484
<Route path="/login" element={<RouteContent><LoginPage /></RouteContent>} />
482485
<Route path={AUTH_CALLBACK_PATH} element={<RouteContent><AuthCallbackPage /></RouteContent>} />

‎src/components/landing/Footer.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,16 @@ const Footer = () => {
4040
<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">
4141
{t('footer.privacy')}
4242
</Link>
43-
{/* English pages: there is no Hungarian or Spanish About or Contact address. */}
43+
{/* English pages: there is no Hungarian or Spanish About, Contact or Press address. */}
4444
<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">
4545
{t('footer.about')}
4646
</Link>
4747
<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">
4848
{t('footer.contact')}
4949
</Link>
50+
<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">
51+
{t('footer.press')}
52+
</Link>
5053
</nav>
5154

5255
<div className="text-center text-sm text-muted-foreground md:text-right">

‎src/config/site.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"height": 512
1919
},
2020
"description": "DebateOwl (formerly DebateLab at debate.pi-stack.org) is an AI debate prep and practice app for students and competitive debaters.",
21-
"sameAs": [],
21+
"disambiguatingDescription": "DebateOwl is an AI debate prep and practice app for students, not a sports or computer-vision product.",
22+
"sameAs": ["https://github.com/Programcode/DebateOwl"],
2223
"parent": {
2324
"name": "Pi-Stack",
2425
"url": "https://pi-stack.org/"

‎src/lib/route-modules.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const routeLoaders = {
3535
"/privacy": cacheLoader(() => import("@/pages/PrivacyPage")),
3636
"/about": cacheLoader(() => import("@/pages/AboutPage")),
3737
"/contact": cacheLoader(() => import("@/pages/ContactPage")),
38+
"/press": cacheLoader(() => import("@/pages/PressPage")),
3839
"/expocit/sources": cacheLoader(() => import("@/pages/SourcesPage")),
3940
"/login": cacheLoader(() => import("@/pages/LoginPage")),
4041
"/auth/callback": cacheLoader(() => import("@/pages/AuthCallbackPage")),

‎src/lib/seo/pages.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@ export const ORGANIZATION_ID = `${SITE_ORIGIN}/#organization`;
1313
* The Organization behind the site, named for the product so search engines
1414
* see one name for one entity. Pi-Stack, which builds it, is its parent, not a
1515
* sameAs: a sameAs profile is the same entity elsewhere. site.json lists only
16-
* profiles that spell the name "DebateOwl"; with none, the node has no sameAs.
16+
* profiles that spell the name "DebateOwl" and that DebateOwl itself runs; with
17+
* none, the node has no sameAs. /press, the fact sheet, is its subjectOf.
1718
*/
1819
export function organizationNode() {
19-
const { name, logo, description, sameAs, parent } = site.organization;
20+
const { name, logo, description, disambiguatingDescription, sameAs, parent } = site.organization;
2021
return {
2122
'@type': 'Organization',
2223
'@id': ORGANIZATION_ID,
@@ -25,10 +26,12 @@ export function organizationNode() {
2526
url: `${SITE_ORIGIN}/`,
2627
logo: { '@type': 'ImageObject', url: `${SITE_ORIGIN}${logo.path}`, width: logo.width, height: logo.height },
2728
description,
29+
disambiguatingDescription,
2830
email: site.contactEmail,
2931
contactPoint: { '@type': 'ContactPoint', contactType: 'customer support', email: site.contactEmail, availableLanguage: ['en', 'hu', 'es'] },
3032
parentOrganization: { '@type': 'Organization', '@id': `${parent.url}#organization`, name: parent.name, url: parent.url },
3133
...(sameAs.length ? { sameAs } : {}),
34+
subjectOf: { '@type': 'WebPage', '@id': `${SITE_ORIGIN}/press#webpage`, url: `${SITE_ORIGIN}/press`, name: PAGE_HEADS['/press'].title },
3235
};
3336
}
3437
export const PRICING = site.pricing;
@@ -60,6 +63,7 @@ export const PAGE_HEADS = {
6063
'/privacy': { ...englishHead('privacy'), canonicalPath: '/privacy' },
6164
'/about': { ...englishHead('about'), canonicalPath: '/about' },
6265
'/contact': { ...englishHead('contact'), canonicalPath: '/contact' },
66+
'/press': { ...englishHead('press'), canonicalPath: '/press' },
6367
'/expocit/sources': { ...englishHead('sources'), canonicalPath: '/expocit/sources' },
6468
...TOOL_PAGE_HEADS,
6569
'*': englishHead('notFound'),
@@ -74,6 +78,7 @@ export const PAGE_HEAD_IDS: Record<PrerenderedRoute, PageHeadId> = {
7478
'/privacy': 'privacy',
7579
'/about': 'about',
7680
'/contact': 'contact',
81+
'/press': 'press',
7782
'/expocit/sources': 'sources',
7883
...TOOL_PAGE_HEAD_IDS,
7984
'*': 'notFound',

0 commit comments

Comments
 (0)