From 099e71341ad54ffa05eda3673da7620237299be4 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 2 Nov 2026 08:55:03 +0000 Subject: [PATCH] Add a /press fact sheet and say what DebateOwl is, and is not Google's AI Overview for "debateowl" described Owl AI on 31 October. The site now states the brand plainly in the places answer engines read. - /press: a one-sentence definition, the former name, the maker, launch year, languages, pricing, logo downloads, and a neutral "Not to be confused with" line for Owl AI (owl.ai) and debatelab.ai. English, Spanish and Hungarian copy; linked from the footer and /about. - The home page subtitle starts "DebateOwl is an AI debate prep and practice app"; the H1 is unchanged. - Organization gets sameAs (the public Programcode/DebateOwl GitHub repo, the only DebateOwl-named profile DebateOwl runs), a disambiguatingDescription, and /press as subjectOf. /about and /press carry the full Organization node too. SoftwareApplication gets the same disambiguatingDescription. - /about says in one line that DebateOwl is not a sports or computer-vision product, and links the press kit. - llms.txt names Owl AI next to debatelab.ai and lists /press and /llms-full.txt. The build writes dist/llms-full.txt: llms.txt plus the visible text of /, /about, /best-ai-for-debate and /press. Co-Authored-By: Claude Opus 5.5 --- docs/operations.md | 2 + package.json | 2 +- public/llms.txt | 4 +- scripts/generate_seo_content.py | 4 +- scripts/llms-full.mjs | 49 +++++++++++++ src/App.tsx | 3 + src/components/landing/Footer.tsx | 5 +- src/config/site.json | 3 +- src/lib/route-modules.ts | 1 + src/lib/seo/pages.ts | 9 ++- src/locales/en.json | 33 ++++++++- src/locales/es.json | 33 ++++++++- src/locales/hu.json | 33 ++++++++- src/pages/AboutPage.tsx | 4 ++ src/pages/PressPage.tsx | 88 +++++++++++++++++++++++ src/prerender/entry.tsx | 31 ++++---- src/test/vercel-deployment-config.test.ts | 2 +- 17 files changed, 283 insertions(+), 23 deletions(-) create mode 100644 scripts/llms-full.mjs create mode 100644 src/pages/PressPage.tsx diff --git a/docs/operations.md b/docs/operations.md index 169a6265..19f875e9 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 `` 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 ``. +[`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 ``, 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). diff --git a/package.json b/package.json index 19f355a9..b2f00e19 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/llms.txt b/public/llms.txt index 90c07c07..eaef47ec 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -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) @@ -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 diff --git a/scripts/generate_seo_content.py b/scripts/generate_seo_content.py index 6ab927bf..48a56a6f 100644 --- a/scripts/generate_seo_content.py +++ b/scripts/generate_seo_content.py @@ -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}) @@ -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 diff --git a/scripts/llms-full.mjs b/scripts/llms-full.mjs new file mode 100644 index 00000000..958b40ff --- /dev/null +++ b/scripts/llms-full.mjs @@ -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
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(//)?.[0]; + if (!main) throw new Error('no
element'); + const text = main + .replace(/<(script|style|svg|noscript)\b[\s\S]*?<\/\1>/g, '') + .replace(/<\/dt>\s*]*>/g, ': ') + .replace(/]*>/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>/)?.[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`); diff --git a/src/App.tsx b/src/App.tsx index 32d489de..656e5fbb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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"]); @@ -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" }, @@ -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>} /> diff --git a/src/components/landing/Footer.tsx b/src/components/landing/Footer.tsx index 81463079..58d1cfb7 100644 --- a/src/components/landing/Footer.tsx +++ b/src/components/landing/Footer.tsx @@ -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"> diff --git a/src/config/site.json b/src/config/site.json index 7d1967f7..8992e7cc 100644 --- a/src/config/site.json +++ b/src/config/site.json @@ -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/" diff --git a/src/lib/route-modules.ts b/src/lib/route-modules.ts index 38c74a74..415ce276 100644 --- a/src/lib/route-modules.ts +++ b/src/lib/route-modules.ts @@ -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")), diff --git a/src/lib/seo/pages.ts b/src/lib/seo/pages.ts index 2b1faeb8..e8f79f53 100644 --- a/src/lib/seo/pages.ts +++ b/src/lib/seo/pages.ts @@ -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, @@ -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; @@ -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'), @@ -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', diff --git a/src/locales/en.json b/src/locales/en.json index d0992f92..2da804c6 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", @@ -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", @@ -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", @@ -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}}?", @@ -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": { diff --git a/src/locales/es.json b/src/locales/es.json index a85f6089..740e6f44 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -882,6 +882,7 @@ "privacy": "Privacidad", "about": "Acerca de", "contact": "Contacto", + "press": "Kit de prensa", "copyright": "© 2026 Pi-Stack. Todos los derechos reservados.", "formerly": "{{product}}, antes DebateLab.", "navLabel": "Pie de página", @@ -896,7 +897,7 @@ }, "hero": { "headline": "{{product}}: preparación y práctica de debate con IA", - "subheadline": "{{product}} te ayuda a preparar casos y practicar rondas con IA.", + "subheadline": "{{product}} es una app de preparación y práctica de debate con IA: crea casos, encuentra puntos débiles y practica rondas cronometradas.", "trialBadge": "Prueba gratis de {{trialDays}} días", "cta": "Comenzar prueba gratis", "trustBullet1": "Sin tarjeta de crédito", @@ -1883,6 +1884,7 @@ "title": "Acerca de {{product}}", "question": "¿Qué es {{product}}?", "answer": "<home>{{product}}</home> es una app de preparación y práctica de debate con IA para estudiantes y debatientes de competición. Te ayuda a escribir un caso, encontrar los puntos débiles de un argumento, buscar evidencia con citas y practicar rondas cronometradas contra un oponente de IA.", + "notThis": "{{product}} es una app de debate para estudiantes, no un producto de deportes ni de visión por computadora. El <press>kit de prensa</press> reúne los datos básicos y el logotipo.", "makerHeading": "Quién lo hace", "maker": "{{product}} lo desarrolla una persona estudiante y lo publica <pistack>Pi-Stack</pistack>.", "nameHeading": "El nombre", @@ -1893,6 +1895,31 @@ "languages": "Inglés, español y húngaro.", "linksHeading": "Más sobre {{product}}" }, + "pressPage": { + "title": "Kit de prensa y datos de {{product}}", + "checked": "Datos comprobados el {{date}}.", + "definition": "<home>{{product}}</home> es una app de preparación y práctica de debate con IA para estudiantes y debatientes de competición que crea casos, encuentra los puntos débiles de los argumentos, busca evidencia con citas y hace rondas de práctica cronometradas contra un oponente de IA.", + "factsHeading": "Datos", + "nameLabel": "Nombre", + "name": "{{product}}, también escrito {{alternateName}}", + "websiteLabel": "Sitio web", + "formerlyLabel": "Nombre anterior", + "formerly": "{{name}}, en {{host}}, renombrado el {{date}}", + "makerLabel": "Creador", + "maker": "Adam (<github>Programcode</github> en GitHub), estudiante y desarrollador en Hungría; lo publica <pistack>Pi-Stack</pistack>", + "launchedLabel": "Lanzamiento", + "launched": "2026, con el nombre {{name}}", + "languagesLabel": "Idiomas", + "pricingLabel": "Precios", + "pricing": "Una prueba gratuita de {{trialDays}} días sin tarjeta de crédito y después {{price}} al mes + impuestos. No hay plan gratuito; las <tools>herramientas de debate gratis</tools> no necesitan cuenta.", + "contactLabel": "Contacto de prensa", + "logoHeading": "Logotipo", + "logoAlt": "El logotipo del búho de {{product}}", + "logoPng": "Descargar el logotipo en PNG (512 × 512)", + "logoSvg": "Descargar el logotipo en SVG", + "notHeading": "No confundir con", + "not": "{{product}} no es Owl AI (owl.ai), una plataforma de visión por computadora en tiempo real para deportes, ni debatelab.ai, otro producto de práctica de debate. Antes del cambio de nombre, {{product}} era DebateLab en debate.pi-stack.org, no en debatelab.ai." + }, "contactPage": { "title": "Contacta con {{product}}", "question": "¿Cómo me pongo en contacto con {{product}}?", @@ -2701,6 +2728,10 @@ "contact": { "title": "Contacto | {{brand}}", "description": "Cómo escribir a {{product}} por correo para pedir ayuda con tu cuenta o la facturación, hacer solicitudes de privacidad o enviar comentarios." + }, + "press": { + "title": "Kit de prensa y datos de {{product}} | {{brand}}", + "description": "Datos sobre {{product}}, la app de debate con IA para estudiantes: nombre anterior, creador, idiomas, precios, logotipo y lo que no es." } }, "validation": { diff --git a/src/locales/hu.json b/src/locales/hu.json index 60a806f7..f06af4cf 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -882,6 +882,7 @@ "privacy": "Adatvédelem", "about": "Rólunk", "contact": "Kapcsolat", + "press": "Sajtóanyag", "copyright": "© 2026 Pi-Stack. Minden jog fenntartva.", "formerly": "{{product}}, korábban DebateLab.", "navLabel": "Lábléc", @@ -896,7 +897,7 @@ }, "hero": { "headline": "{{product}}: MI-alapú vitafelkészítés és gyakorlás", - "subheadline": "A {{product}} segít eseteket készíteni és fordulókat gyakorolni MI-vel.", + "subheadline": "A {{product}} MI-alapú vitafelkészítő és gyakorló alkalmazás: építs esetet, keresd meg a gyenge pontokat, és gyakorolj időre.", "trialBadge": "{{trialDays}} napos ingyenes próba", "cta": "Ingyenes próba indítása", "trustBullet1": "Bankkártya nem szükséges", @@ -1883,6 +1884,7 @@ "title": "A {{product}} bemutatása", "question": "Mi az a {{product}}?", "answer": "A <home>{{product}}</home> MI-alapú vitafelkészítő és gyakorló alkalmazás diákoknak és versenyzőknek. Segít megírni az esetet, megtalálni egy érvelés gyenge pontjait, hivatkozásokkal ellátott bizonyítékot keresni, és időre gyakorolni egy MI-ellenféllel.", + "notThis": "A {{product}} vitaalkalmazás diákoknak, nem sport- vagy gépilátás-termék. A <press>sajtóanyagban</press> megtalálod a rövid adatokat és a logót.", "makerHeading": "Ki készíti", "maker": "A {{product}} alkalmazást egy diák fejlesztő építi, és a <pistack>Pi-Stack</pistack> adja ki.", "nameHeading": "A név", @@ -1893,6 +1895,31 @@ "languages": "Angol, spanyol és magyar.", "linksHeading": "Továbbiak a {{product}} alkalmazásról" }, + "pressPage": { + "title": "A {{product}} sajtóanyaga és adatai", + "checked": "Az adatokat {{date}} napján ellenőriztük.", + "definition": "A <home>{{product}}</home> MI-alapú vitafelkészítő és gyakorló alkalmazás diákoknak és versenyzőknek: esetet épít, megkeresi az érvelések gyenge pontjait, hivatkozásokkal ellátott bizonyítékot keres, és időzített gyakorlóköröket futtat egy MI-ellenféllel.", + "factsHeading": "Adatok", + "nameLabel": "Név", + "name": "{{product}}, más írásmóddal {{alternateName}}", + "websiteLabel": "Weboldal", + "formerlyLabel": "Korábbi név", + "formerly": "{{name}}, a {{host}} címen; átnevezve: {{date}}", + "makerLabel": "Készítő", + "maker": "Ádám (a GitHubon <github>Programcode</github>), magyarországi diák fejlesztő; kiadó: <pistack>Pi-Stack</pistack>", + "launchedLabel": "Indulás", + "launched": "2026, {{name}} néven", + "languagesLabel": "Nyelvek", + "pricingLabel": "Árak", + "pricing": "{{trialDays}} napos ingyenes próbaidőszak bankkártya nélkül, utána havi {{price}} + adó. Ingyenes csomag nincs; az <tools>ingyenes vitaeszközökhöz</tools> nem kell fiók.", + "contactLabel": "Sajtókapcsolat", + "logoHeading": "Logó", + "logoAlt": "A {{product}} bagolylogója", + "logoPng": "A logó letöltése PNG-ben (512 × 512)", + "logoSvg": "A logó letöltése SVG-ben", + "notHeading": "Nem összetévesztendő", + "not": "A {{product}} nem azonos az Owl AI-jal (owl.ai), amely valós idejű gépi látás platform sporthoz, és a debatelab.ai-jal sem, amely egy másik vitagyakorló termék. Átnevezése előtt a {{product}} DebateLab néven a debate.pi-stack.org címen futott, nem a debatelab.ai-n." + }, "contactPage": { "title": "Kapcsolat", "question": "Hogyan léphetek kapcsolatba a {{product}} csapatával?", @@ -2701,6 +2728,10 @@ "contact": { "title": "Kapcsolat | {{brand}}", "description": "Így írhatsz a {{product}} csapatának e-mailben fiókkal vagy számlázással kapcsolatos segítségért, adatvédelmi kérésekkel és visszajelzéssel." + }, + "press": { + "title": "A {{product}} sajtóanyaga és adatai | {{brand}}", + "description": "Adatok a {{product}} MI-alapú vitafelkészítő alkalmazásról: korábbi név, készítő, nyelvek, árak, letölthető logó, és hogy mi nem." } }, "validation": { diff --git a/src/pages/AboutPage.tsx b/src/pages/AboutPage.tsx index d485938c..2f921f16 100644 --- a/src/pages/AboutPage.tsx +++ b/src/pages/AboutPage.tsx @@ -25,6 +25,9 @@ export default function AboutPage() { <p> <Trans i18nKey="aboutPage.answer" values={{ product }} components={{ home: <a href={`${site.origin}/`} className={linkClass} /> }} /> </p> + <p className="mt-4"> + <Trans i18nKey="aboutPage.notThis" values={{ product }} components={{ press: <Link to="/press" className={linkClass} /> }} /> + </p> </section> <section> <h2 className="text-lg font-semibold">{t('aboutPage.makerHeading')}</h2> @@ -58,6 +61,7 @@ export default function AboutPage() { <li><Link to="/privacy" className={linkClass}>{t('footer.privacy')}</Link></li> <li><Link to="/terms" className={linkClass}>{t('footer.terms')}</Link></li> <li><Link to="/contact" className={linkClass}>{t('footer.contact')}</Link></li> + <li><Link to="/press" className={linkClass}>{t('footer.press')}</Link></li> </ul> </section> </div> diff --git a/src/pages/PressPage.tsx b/src/pages/PressPage.tsx new file mode 100644 index 00000000..406ff86c --- /dev/null +++ b/src/pages/PressPage.tsx @@ -0,0 +1,88 @@ +import type { ReactNode } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import site from '@/config/site.json'; +import { PublicPageShell } from '@/components/layout/PublicPageShell'; +import { formatPlanPrice, PRO_MONTHLY_PRICE, TRIAL_DAYS } from '@/lib/billing/plan'; +import { usePageHead } from '@/lib/seo/use-page-head'; + +const linkClass = 'font-medium text-primary underline underline-offset-4 hover:text-primary/80'; +// The day the facts below were last checked; move it when they are. +const CHECKED_ON = '2026-11-02'; +const MAKER_GITHUB = 'https://github.com/Programcode'; + +/** + * The fact sheet for press and answer engines: what DebateOwl is, who makes it + * and what it is not. Verified facts only, and no phone number or street address. + */ +export default function PressPage() { + const { t, i18n } = useTranslation(); + usePageHead('/press'); + const product = site.product; + const longDate = (iso: string) => new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }) + .format(new Date(`${iso}T00:00:00Z`)); + const facts: Array<[string, ReactNode]> = [ + [t('pressPage.nameLabel'), <>{t('pressPage.name', { product, alternateName: site.alternateName })}</>], + [t('pressPage.websiteLabel'), <a href={`${site.origin}/`} className={linkClass}>{new URL(site.origin).host}</a>], + [t('pressPage.formerlyLabel'), <>{t('pressPage.formerly', { name: site.formerly.name, host: site.formerly.host, date: longDate(site.formerly.until) })}</>], + [t('pressPage.makerLabel'), <Trans i18nKey="pressPage.maker" components={{ github: <a href={MAKER_GITHUB} className={linkClass} />, pistack: <a href={site.organization.parent.url} className={linkClass} /> }} />], + [t('pressPage.launchedLabel'), <>{t('pressPage.launched', { name: site.formerly.name })}</>], + [t('pressPage.languagesLabel'), <>{t('aboutPage.languages')}</>], + [t('pressPage.pricingLabel'), ( + <Trans + i18nKey="pressPage.pricing" + values={{ trialDays: TRIAL_DAYS, price: formatPlanPrice(PRO_MONTHLY_PRICE, i18n.language) }} + components={{ tools: <Link to="/tools" className={linkClass} /> }} + /> + )], + [t('pressPage.contactLabel'), <a href={`mailto:${site.contactEmail}`} className={linkClass}>{site.contactEmail}</a>], + ]; + + return ( + <PublicPageShell className="max-w-[65ch]" footer> + <h1 className="text-3xl font-bold tracking-tight text-foreground">{t('pressPage.title', { product })}</h1> + <p className="mt-2 text-sm text-muted-foreground">{t('pressPage.checked', { date: longDate(CHECKED_ON) })}</p> + + <div className="mt-8 space-y-8 text-base leading-7 text-foreground"> + <section> + <h2 id="short-answer" className="text-lg font-semibold">{t('aboutPage.question', { product })}</h2> + <p> + <Trans i18nKey="pressPage.definition" values={{ product }} components={{ home: <a href={`${site.origin}/`} className={linkClass} /> }} /> + </p> + </section> + <section> + <h2 className="text-lg font-semibold">{t('pressPage.factsHeading')}</h2> + <dl className="mt-2 grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-[max-content_1fr]"> + {facts.map(([label, value]) => ( + <div key={label} className="contents"> + <dt className="font-semibold">{label}</dt> + <dd className="mb-2 sm:mb-0">{value}</dd> + </div> + ))} + </dl> + </section> + <section> + <h2 className="text-lg font-semibold">{t('pressPage.logoHeading')}</h2> + <img src={site.organization.logo.path} alt={t('pressPage.logoAlt', { product })} width={96} height={96} className="mt-3 rounded-2xl" /> + <ul className="mt-3 list-disc space-y-1 pl-6"> + <li><a href={site.organization.logo.path} download="debateowl-logo-512.png" className={linkClass}>{t('pressPage.logoPng')}</a></li> + <li><a href="/owl-mark.svg" download="debateowl-logo.svg" className={linkClass}>{t('pressPage.logoSvg')}</a></li> + </ul> + </section> + <section> + <h2 className="text-lg font-semibold">{t('pressPage.notHeading')}</h2> + <p>{t('pressPage.not', { product })}</p> + </section> + <section> + <h2 className="text-lg font-semibold">{t('aboutPage.linksHeading', { product })}</h2> + <ul className="list-disc space-y-1 pl-6"> + <li><Link to="/about" className={linkClass}>{t('footer.about')}</Link></li> + {/* The pricing guide is a static page, not an app route: a plain link loads it from the server. */} + <li><a href="/debateowl-pricing" className={linkClass}>{t('footer.pricingGuide')}</a></li> + <li><Link to="/contact" className={linkClass}>{t('footer.contact')}</Link></li> + </ul> + </section> + </div> + </PublicPageShell> + ); +} diff --git a/src/prerender/entry.tsx b/src/prerender/entry.tsx index e49619a7..df752c04 100644 --- a/src/prerender/entry.tsx +++ b/src/prerender/entry.tsx @@ -18,6 +18,7 @@ import TermsPage from '@/pages/TermsPage'; import PrivacyPage from '@/pages/PrivacyPage'; import AboutPage from '@/pages/AboutPage'; import ContactPage from '@/pages/ContactPage'; +import PressPage from '@/pages/PressPage'; import NotFound from '@/pages/NotFound'; import SourcesPage from '@/pages/SourcesPage'; import { PRACTICE_TOOLS, PREPARE_TOOLS, PREP_MODE_COUNT, UTILITY_TOOLS } from '@/components/features/tools/tool-links'; @@ -124,6 +125,7 @@ function landingStructuredData(dateModified: string | undefined) { url: `${SITE_ORIGIN}/`, image: `${SITE_ORIGIN}/hero-visual.jpg?v=2`, description: SOFTWARE_DESCRIPTION, + disambiguatingDescription: site.organization.disambiguatingDescription, 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 @@ -175,7 +177,7 @@ function localizedLandingStructuredData(head: PageHead, language: PageLanguage, } /** Home, then the page: the trail the guides and tool pages already carry. */ -function breadcrumbList(route: '/terms' | '/privacy' | '/about' | '/contact' | '/expocit/sources', name: string) { +function breadcrumbList(route: '/terms' | '/privacy' | '/about' | '/contact' | '/press' | '/expocit/sources', name: string) { return { '@type': 'BreadcrumbList', itemListElement: [ @@ -195,17 +197,22 @@ function legalStructuredData(route: '/terms' | '/privacy', page: 'termsPage' | ' }; } -// The day the About and Contact copy last changed; move it when their text does. -const ABOUT_CONTACT_MODIFIED = '2026-10-28'; +// 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' }; -/** /about as an AboutPage about the Organization the home page defines; /contact as a ContactPage. */ -function aboutContactStructuredData(route: '/about' | '/contact') { - const about = route === '/about'; +/** + * /about as an AboutPage and /press as a WebPage, each about the Organization, + * which they define in full as the home page does; /contact as a ContactPage. + */ +function companyStructuredData(route: '/about' | '/contact' | '/press') { + const types = { '/about': 'AboutPage', '/contact': 'ContactPage', '/press': 'WebPage' }; + const labels = { '/about': 'footer.about', '/contact': 'footer.contact', '/press': 'footer.press' }; return { '@context': 'https://schema.org', '@graph': [ - { ...webPage(route, ABOUT_CONTACT_MODIFIED), '@type': about ? 'AboutPage' : 'ContactPage', about: { '@id': ORGANIZATION_ID } }, - breadcrumbList(route, i18n.t(about ? 'footer.about' : 'footer.contact', { lng: 'en' })), + ...(route === '/contact' ? [] : [organizationNode()]), + { ...webPage(route, PAGE_MODIFIED[route]), '@type': types[route], about: { '@id': ORGANIZATION_ID } }, + breadcrumbList(route, i18n.t(labels[route], { lng: 'en' })), ], }; } @@ -288,13 +295,13 @@ export async function prerenderPages({ landingModified }: { landingModified?: st body: render('/privacy', PrivacyPage), structuredData: legalStructuredData('/privacy', 'privacyPage'), }, - ...(['/about', '/contact'] as const).map((route) => ({ + ...([['/about', 'AboutPage', AboutPage], ['/contact', 'ContactPage', ContactPage], ['/press', 'PressPage', PressPage]] as const).map(([route, name, Page]) => ({ route, file: `${route.slice(1)}/index.html`, - module: route === '/about' ? 'src/pages/AboutPage.tsx' : 'src/pages/ContactPage.tsx', + module: `src/pages/${name}.tsx`, head: PAGE_HEADS[route], - body: render(route, route === '/about' ? AboutPage : ContactPage), - structuredData: aboutContactStructuredData(route), + body: render(route, Page), + structuredData: companyStructuredData(route), })), // The tool pages render their own JSON-LD in the body. ...TOOL_PAGES.map(({ route, module, Page }) => ({ diff --git a/src/test/vercel-deployment-config.test.ts b/src/test/vercel-deployment-config.test.ts index 659f04a1..27e7599b 100644 --- a/src/test/vercel-deployment-config.test.ts +++ b/src/test/vercel-deployment-config.test.ts @@ -316,7 +316,7 @@ describe("Vercel frontend deployment configuration", () => { const packageJson = loadPackageJson(); expect(indexHtml).not.toMatch(/rel="(?:modulepreload|preload|prefetch)"[^>]*(?:pages|LandingPage|LoginPage|HomePage|GeneratePage|AttackPage)/i); - expect(packageJson.scripts?.build).toBe("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"); + expect(packageJson.scripts?.build).toBe("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"); }); it("answers app routes with the cached shell and never stores a navigation as another page", () => {