feat(website): add search-intent titles, FAQ schema, and breadcrumbs
All checks were successful
CI — Website / Website — typecheck, build (pull_request) Successful in 1m2s

Give Google unique snippets and FAQ rich results, and keep robots/sitemap from being cached as HTML.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john kevin asprec
2026-09-04 07:08:57 +08:00
parent 3150e16248
commit 03ad49f523
19 changed files with 347 additions and 37 deletions

View File

@@ -6,23 +6,31 @@
<meta name="robots" content="index, follow" />
<meta
name="description"
content="LexAI — bring-your-own-LLM writing and code assist. Chrome, VS Code, and CLI. No LexAI servers, no subscription."
content="Free Chrome extension and VS Code assist. Bring your own OpenAI, Anthropic, Groq, or OpenRouter key. No LexAI servers, account, or subscription."
/>
<link rel="canonical" href="https://lexai.juankibin.space/" />
<meta name="theme-color" content="#f7f7f5" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="LexAI" />
<meta property="og:title" content="LexAI — BYO-LLM writing &amp; code assist" />
<meta
property="og:title"
content="LexAI — Grammarly-style writing help with your own API key"
/>
<meta
property="og:description"
content="LexAI — bring-your-own-LLM writing and code assist. Chrome, VS Code, and CLI. No LexAI servers, no subscription."
content="Free Chrome extension and VS Code assist. Bring your own OpenAI, Anthropic, Groq, or OpenRouter key. No LexAI servers, account, or subscription."
/>
<meta property="og:url" content="https://lexai.juankibin.space/" />
<meta property="og:image" content="https://lexai.juankibin.space/icon.svg" />
<meta property="og:image:alt" content="LexAI logo" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="LexAI — BYO-LLM writing &amp; code assist" />
<meta
name="twitter:title"
content="LexAI — Grammarly-style writing help with your own API key"
/>
<meta
name="twitter:description"
content="LexAI — bring-your-own-LLM writing and code assist. Chrome, VS Code, and CLI. No LexAI servers, no subscription."
content="Free Chrome extension and VS Code assist. Bring your own OpenAI, Anthropic, Groq, or OpenRouter key. No LexAI servers, account, or subscription."
/>
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -31,7 +39,7 @@
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<title>LexAI — BYO-LLM writing &amp; code assist</title>
<title>LexAI — Grammarly-style writing help with your own API key</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",

View File

@@ -7,8 +7,64 @@ const distHtml = join(root, 'dist', 'index.html');
const { origin, routes } = JSON.parse(
readFileSync(join(root, 'src', 'seo.routes.json'), 'utf8'),
);
const faq = JSON.parse(readFileSync(join(root, 'src', 'faq.json'), 'utf8'));
function apply(html, { title, description, url, robots }) {
const CHROME_STORE =
'https://chromewebstore.google.com/detail/bagpcheidbkfgijnnmolnkgagibbjfnk';
const VSCODE_MARKETPLACE =
'https://marketplace.visualstudio.com/items?itemName=JuanKibin.lexai-vscode';
const REPO = 'https://git.juankibin.space/kibin/LexAI';
const homeDescription = routes.find((r) => r.path === '/')?.description ?? '';
function softwareGraph() {
return {
'@type': 'SoftwareApplication',
name: 'LexAI',
applicationCategory: 'BrowserApplication',
applicationSubCategory: 'Writing assistance',
operatingSystem: 'Chrome, VS Code, Windows, macOS, Linux',
url: `${origin}/`,
downloadUrl: [CHROME_STORE, VSCODE_MARKETPLACE],
license: `${REPO}/src/branch/main/LICENSE`,
sameAs: [REPO, CHROME_STORE, VSCODE_MARKETPLACE],
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
};
}
function jsonLd(route) {
const url = route.path === '/' ? `${origin}/` : `${origin}${route.path}`;
const graph = [
{
'@type': 'WebSite',
name: 'LexAI',
url: `${origin}/`,
description: homeDescription,
},
softwareGraph(),
];
if (route.path === '/') {
graph.push({
'@type': 'FAQPage',
mainEntity: faq.map((item) => ({
'@type': 'Question',
name: item.q,
acceptedAnswer: { '@type': 'Answer', text: item.a },
})),
});
} else {
graph.push({
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: `${origin}/` },
{ '@type': 'ListItem', position: 2, name: route.title, item: url },
],
});
}
return JSON.stringify({ '@context': 'https://schema.org', '@graph': graph });
}
function apply(html, { title, description, url, robots, ld }) {
let out = html;
out = out.replace(/<title>[\s\S]*?<\/title>/, `<title>${title}</title>`);
out = out.replace(
@@ -28,6 +84,10 @@ function apply(html, { title, description, url, robots }) {
`$1${description}$2`,
);
out = out.replace(/(<link\s+rel="canonical"\s+href=")[^"]*(")/, `$1${url}$2`);
out = out.replace(
/<script type="application\/ld\+json">[\s\S]*?<\/script>/,
`<script type="application/ld+json">${ld}</script>`,
);
return out;
}
@@ -50,6 +110,7 @@ for (const route of routes) {
description: route.description,
url,
robots: 'index, follow',
ld: jsonLd(route),
}),
);
written.push(file);
@@ -62,8 +123,30 @@ writeFileSync(
description: 'That address is not a LexAI page.',
url: `${origin}/`,
robots: 'noindex, follow',
ld: JSON.stringify({
'@context': 'https://schema.org',
'@graph': [{ '@type': 'WebSite', name: 'LexAI', url: `${origin}/` }, softwareGraph()],
}),
}),
);
written.push('404.html');
const today = new Date().toISOString().slice(0, 10);
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${routes
.map(
(r, i) => ` <url>
<loc>${r.path === '/' ? `${origin}/` : `${origin}${r.path}`}</loc>
<lastmod>${today}</lastmod>
<changefreq>${i < 2 ? 'weekly' : 'monthly'}</changefreq>
<priority>${r.path === '/' ? '1.0' : r.path === '/docs' ? '0.8' : '0.5'}</priority>
</url>`,
)
.join('\n')}
</urlset>
`;
writeFileSync(join(root, 'dist', 'sitemap.xml'), sitemap);
written.push('sitemap.xml');
console.log('SEO HTML written for', written.join(', '));

View File

@@ -11,11 +11,13 @@ server {
}
location = /robots.txt {
add_header Cache-Control "public, max-age=300";
try_files /robots.txt =404;
}
location = /sitemap.xml {
default_type application/xml;
add_header Cache-Control "public, max-age=300";
try_files /sitemap.xml =404;
}

View File

@@ -82,7 +82,12 @@ const server = createServer((req, res) => {
}
const type = MIME[extname(file).toLowerCase()] ?? 'application/octet-stream';
res.writeHead(status, { 'Content-Type': type });
const headers = { 'Content-Type': type };
const base = file.replace(/\\/g, '/');
if (base.endsWith('/robots.txt') || base.endsWith('/sitemap.xml')) {
headers['Cache-Control'] = 'public, max-age=300';
}
res.writeHead(status, headers);
if (req.method === 'HEAD') {
res.end();
return;

View File

@@ -0,0 +1,24 @@
import { Link } from 'react-router-dom';
type Crumb = { label: string; to?: string };
export function Breadcrumbs({ items }: { items: Crumb[] }) {
return (
<nav className="crumbs" aria-label="Breadcrumb">
<ol>
{items.map((item, i) => {
const last = i === items.length - 1;
return (
<li key={item.label}>
{last || !item.to ? (
<span aria-current={last ? 'page' : undefined}>{item.label}</span>
) : (
<Link to={item.to}>{item.label}</Link>
)}
</li>
);
})}
</ol>
</nav>
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { canonicalUrl, seoForPath, SITE_ORIGIN } from '../seo';
import { canonicalUrl, jsonLdForPath, seoForPath, SITE_ORIGIN } from '../seo';
function upsertMeta(selector: string, attrs: Record<string, string>, content: string) {
let el = document.head.querySelector(selector) as HTMLMetaElement | null;
@@ -41,6 +41,15 @@ export function SeoHead() {
upsertMeta('meta[property="og:url"]', { property: 'og:url' }, url);
upsertMeta('meta[name="twitter:title"]', { name: 'twitter:title' }, seo.title);
upsertMeta('meta[name="twitter:description"]', { name: 'twitter:description' }, seo.description);
let ld = document.head.querySelector(
'script[type="application/ld+json"]',
) as HTMLScriptElement | null;
if (!ld) {
ld = document.createElement('script');
ld.type = 'application/ld+json';
document.head.appendChild(ld);
}
ld.textContent = JSON.stringify(jsonLdForPath(pathname));
}, [pathname]);
return null;

View File

@@ -8,6 +8,7 @@ export function SiteFooter() {
<p style={{ margin: 0 }}>© {new Date().getFullYear()} LexAI · MIT</p>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<Link to="/docs">Docs</Link>
<Link to={{ pathname: '/', hash: 'faq' }}>FAQ</Link>
<Link to="/contribute">Contribute</Link>
<Link to="/community">Community</Link>
<a href={REPO} target="_blank" rel="noreferrer">

View File

@@ -0,0 +1,22 @@
[
{
"q": "Is LexAI free?",
"a": "Yes. LexAI has no subscription and no account. You only pay the LLM provider you choose. Groq and OpenRouter have free-tier keys you can use to start."
},
{
"q": "Do I need my own API key?",
"a": "Yes. LexAI is bring-your-own-LLM: paste a key for OpenAI, Anthropic, Groq, or OpenRouter in Chrome Options or VS Code settings. There is no LexAI backend."
},
{
"q": "Is LexAI a Grammarly alternative?",
"a": "For writing help, yes: select text on any webpage and Fix, Rephrase, Shorten, Expand, or Explain. Unlike Grammarly, your key stays on your device and text goes only to the provider you pick."
},
{
"q": "How is LexAI different from GitHub Copilot?",
"a": "In VS Code or Cursor you select code, give an instruction, and apply a suggestion with file context. It is not tab-complete autocomplete. You use your own provider key instead of a Copilot subscription."
},
{
"q": "Does LexAI store my text or API key?",
"a": "No LexAI servers sit in the path. The Chrome key is stored on your device; VS Code uses Secret Storage. Text is sent only to the provider endpoint you configure. There is no LexAI telemetry."
}
]

View File

@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
import { Breadcrumbs } from '../components/Breadcrumbs';
import {
CHROME_STORE,
REPO,
@@ -12,6 +13,7 @@ export function CommunityPage() {
return (
<div className="wrap page">
<article className="docs-content">
<Breadcrumbs items={[{ label: 'Home', to: '/' }, { label: 'Community' }]} />
<h1>Community</h1>
<p className="intro">
LexAI is maintained in the open. Use these links to install, report issues, and follow the

View File

@@ -1,9 +1,11 @@
import { Breadcrumbs } from '../components/Breadcrumbs';
import { REPO, REPO_CONDUCT, REPO_CONTRIBUTING } from '../links';
export function ContributePage() {
return (
<div className="wrap page">
<article className="docs-content">
<Breadcrumbs items={[{ label: 'Home', to: '/' }, { label: 'Contribute' }]} />
<h1>Contribute</h1>
<p className="intro">
LexAI is a monorepo: Chrome, VS Code, CLI, website, and a shared library. Please follow

View File

@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Breadcrumbs } from '../components/Breadcrumbs';
import { FREE_PROVIDERS } from '../freeProviders';
import { CHROME_STORE, REPO, VSCODE_MARKETPLACE } from '../links';
@@ -38,6 +39,7 @@ export function DocsPage() {
</aside>
<article className="docs-content">
<Breadcrumbs items={[{ label: 'Home', to: '/' }, { label: 'Docs' }]} />
<h1>Documentation</h1>
<p className="intro">
LexAI is an open-source, bring-your-own-LLM assistant. This page covers every package and

View File

@@ -1,6 +1,7 @@
import { Link, useNavigate } from 'react-router-dom';
import { LogoMark } from '../components/LogoMark';
import { FREE_PROVIDERS, FREE_PROVIDER_BADGES, LEXAI_FREE_HIGHLIGHTS } from '../freeProviders';
import faq from '../faq.json';
import { CHROME_STORE, REPO, VSCODE_MARKETPLACE } from '../links';
function openDocsSection(navigate: ReturnType<typeof useNavigate>, id: string) {
@@ -232,6 +233,24 @@ export function HomePage() {
</ul>
</div>
</section>
<section className="section" id="faq">
<div className="wrap">
<p className="section-kicker">FAQ</p>
<h2>Questions</h2>
<p className="section-lead">
Short answers for people comparing LexAI to Grammarly, Copilot, and hosted writing tools.
</p>
<div className="faq-list">
{faq.map((item) => (
<details key={item.q} className="faq-item">
<summary>{item.q}</summary>
<p>{item.a}</p>
</details>
))}
</div>
</div>
</section>
</>
);
}

View File

@@ -3,23 +3,23 @@
"routes": [
{
"path": "/",
"title": "LexAI — BYO-LLM writing & code assist",
"description": "LexAI — bring-your-own-LLM writing and code assist. Chrome, VS Code, and CLI. No LexAI servers, no subscription."
"title": "LexAI — Grammarly-style writing help with your own API key",
"description": "Free Chrome extension and VS Code assist. Bring your own OpenAI, Anthropic, Groq, or OpenRouter key. No LexAI servers, account, or subscription."
},
{
"path": "/docs",
"title": "Docs — LexAI",
"description": "Install and use LexAI in Chrome, VS Code, and the CLI. Free LLM setup, privacy, and the shared core."
"title": "LexAI docs — Chrome, VS Code, CLI, and free LLM setup",
"description": "Install LexAI, add a free Groq or OpenRouter key, and use writing actions in Chrome or code assist in VS Code. Privacy and shared-core notes included."
},
{
"path": "/contribute",
"title": "Contribute — LexAI",
"description": "How to report bugs, set up the LexAI monorepo, and send focused pull requests."
"title": "Contribute to LexAI — open-source BYO-LLM assistant",
"description": "Report bugs, set up the LexAI monorepo (Chrome, VS Code, CLI, website), and send focused pull requests. MIT license, Code of Conduct."
},
{
"path": "/community",
"title": "Community — LexAI",
"description": "LexAI source, issues, Chrome Web Store, VS Code Marketplace, and Code of Conduct."
"title": "LexAI community — source, issues, and installs",
"description": "Chrome Web Store, VS Code Marketplace, Gitea repository, issue tracker, and Code of Conduct for the LexAI project."
}
]
}

View File

@@ -1,4 +1,6 @@
import faq from './faq.json';
import routesFile from './seo.routes.json';
import { CHROME_STORE, REPO, VSCODE_MARKETPLACE } from './links';
export const SITE_ORIGIN = routesFile.origin;
@@ -32,3 +34,53 @@ export function canonicalUrl(path: string): string {
if (!path || path === '/') return `${SITE_ORIGIN}/`;
return `${SITE_ORIGIN}${path.startsWith('/') ? path : `/${path}`}`;
}
function softwareGraph() {
return {
'@type': 'SoftwareApplication',
name: 'LexAI',
applicationCategory: 'BrowserApplication',
applicationSubCategory: 'Writing assistance',
operatingSystem: 'Chrome, VS Code, Windows, macOS, Linux',
url: `${SITE_ORIGIN}/`,
downloadUrl: [CHROME_STORE, VSCODE_MARKETPLACE],
license: `${REPO}/src/branch/main/LICENSE`,
sameAs: [REPO, CHROME_STORE, VSCODE_MARKETPLACE],
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
};
}
export function jsonLdForPath(pathname: string): Record<string, unknown> {
const seo = seoForPath(pathname);
const url = seo.index ? canonicalUrl(seo.path) : `${SITE_ORIGIN}${pathname}`;
const graph: unknown[] = [
{
'@type': 'WebSite',
name: 'LexAI',
url: `${SITE_ORIGIN}/`,
description: seoForPath('/').description,
},
softwareGraph(),
];
if (seo.path === '/') {
graph.push({
'@type': 'FAQPage',
mainEntity: faq.map((item) => ({
'@type': 'Question',
name: item.q,
acceptedAnswer: { '@type': 'Answer', text: item.a },
})),
});
} else if (seo.index) {
graph.push({
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: `${SITE_ORIGIN}/` },
{ '@type': 'ListItem', position: 2, name: seo.title, item: url },
],
});
}
return { '@context': 'https://schema.org', '@graph': graph };
}

View File

@@ -264,6 +264,60 @@ main {
color: var(--muted);
}
.faq-list {
display: grid;
gap: 0.5rem;
}
.faq-item {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.faq-item summary {
cursor: pointer;
list-style: none;
min-height: 44px;
padding: 0.75rem 1rem;
font-weight: 560;
}
.faq-item summary::-webkit-details-marker {
display: none;
}
.faq-item p {
margin: 0;
padding: 0 1rem 1rem;
color: var(--muted);
}
.crumbs {
margin: 0 0 1rem;
font-size: 0.875rem;
}
.crumbs ol {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin: 0;
padding: 0;
list-style: none;
color: var(--muted);
}
.crumbs li:not(:last-child)::after {
content: '/';
margin-left: 0.35rem;
color: var(--line);
}
.crumbs a {
color: var(--muted);
}
.feature-list {
display: grid;
gap: 0;