4 Commits

Author SHA1 Message Date
fc80e9b08c Merge pull request 'feat(website): add search-intent titles, FAQ schema, and breadcrumbs' (#6) from dev into main
All checks were successful
CI — Website / Website — typecheck, build (push) Successful in 59s
Reviewed-on: #6
2026-09-03 23:15:19 +00:00
john kevin asprec
03ad49f523 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>
2026-09-04 07:08:57 +08:00
1788b2b5be Merge pull request 'google search index' (#5) from dev into main
All checks were successful
CI — Website / Website — typecheck, build (push) Successful in 54s
Reviewed-on: #5
2026-09-03 22:51:20 +00:00
john kevin asprec
3150e16248 google search index
All checks were successful
CI — Website / Website — typecheck, build (pull_request) Successful in 57s
2026-09-03 21:40:55 +08:00
32 changed files with 723 additions and 36 deletions

View File

@@ -22,6 +22,16 @@ Add an entry when a choice is hard to reverse, shapes future work, or a future m
## Log
### D-2026-09-03-01 — Website path URLs for Google indexing
- **Status:** accepted
- **Context:** The marketing site used HashRouter (`/#/docs`). Google does not treat hash fragments as indexable URLs. Production already had SPA fallbacks.
- **Decision:** Switch to BrowserRouter; prerender unique HTML for `/`, `/docs`, `/contribute`, `/community`; ship `/robots.txt` and `/sitemap.xml`; unknown paths return HTTP 404; rewrite legacy `#/…` hashes on load.
- **Alternatives considered:** Keep hashes (rejected: not crawlable as pages); full SSR (rejected: extra stack for a static brochure).
- **Consequences:** Coolify/nginx must keep SPA fallback for the four routes. Old social posts with `#/docs` still work via the hash rewrite.
- **Verification:** `npm run website:build`; GET `/robots.txt`, `/sitemap.xml`, `/docs` 200 with unique titles; GET `/no-such-page` 404.
- **Owner / date:** John Kevin / lead, 2026-09-03
### D-2026-08-13-01 — VS Code port: shared `src/lib` + native v1 UX
- **Status:** accepted

View File

@@ -31,3 +31,6 @@ Tap targets ≥ 40px; body text contrast AA.
| Feature list | Two-column hairline rows |
| PackageCard | Chrome / VS Code / CLI |
| DocsSection | Package docs blocks |
| Not found | Unknown path: H1 + Home / Docs |
| Breadcrumbs | Inner pages: Home / current |
| FAQ | Home `<details>` list; copy must match FAQ JSON-LD |

View File

@@ -4,8 +4,6 @@
## Current state
- **Outcome:** Coolify `Cannot GET /` fixed — website now has a production static server and a Dockerfile.
- **Cause:** Nixpacks built Vite `dist/` but had no `start` command; README also told Coolify to publish `/dist` (absolute empty path).
- **Shipped:** `packages/website/server.mjs` (`npm start` on `0.0.0.0:$PORT`); `Dockerfile` + `nginx.conf`; `nixpacks.toml` `[start]`; Vite `base` is `/`.
- **Verified:** `website:typecheck` + `website:build`; local `node server.mjs` → GET `/` 200 (LexAI HTML), `/healthz` 200, JS asset `text/javascript`.
- **Next smallest action:** Redeploy the Coolify app with Base Directory `packages/website` (no leading slash), static site **off**, Dockerfile port 80 or Nixpacks port 3000.
- **Outcome:** Sitemap accepted in GSC. SEO pass: intent titles, home FAQ + FAQPage JSON-LD, breadcrumbs, lastmod sitemap, 5-min cache on robots/sitemap.
- **Verified:** `website:typecheck` + `website:build` OK (inject wrote route HTML + sitemap.xml).
- **Next smallest action:** Merge/deploy this to `main`, purge Cloudflare `/robots.txt`, then in GSC request indexing for `/` and `/docs`.

View File

@@ -32,7 +32,7 @@
| Shared lib | `src/lib/` |
| VS Code | `packages/vscode/` |
| CLI | `packages/cli/` |
| Website | `packages/website/` (Vite React; Home, Docs, Contribute, Community). Production: `npm start` → `server.mjs`, or `Dockerfile` (nginx :80). Coolify base `packages/website`, static site off. |
| Website | `packages/website/` (Vite React; Home, Docs, Contribute, Community). BrowserRouter paths `/docs` etc. Origin `https://lexai.juankibin.space`. Production: `npm start` → `server.mjs`, or `Dockerfile` (nginx :80). Coolify base `packages/website`, static site off. |
| OSS meta | root `README.md`, `LICENSE`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md` |
| Issue/PR templates | `.gitea/ISSUE_TEMPLATE/`, `.gitea/PULL_REQUEST_TEMPLATE.md` |
| CI workflows | `.gitea/workflows/ci-{chrome,vscode,cli,website}.yml` (no deploy/release yet) |

View File

@@ -2,33 +2,28 @@
> For the owner. What works, how to see it, and what's waiting on you — plain language.
**Updated:** 2026-08-17 · **Overall:** Website Coolify 404 is fixed in the repo; needs a redeploy
**Updated:** 2026-09-04 · **Overall:** Search Console sitemap is success; next deploy the SEO copy/FAQ pass
## What works now
- Chrome extension: select on any page → AI action → Replace/Copy
- VS Code extension: select in editor → LexAI context menu / Command Palette → selection replaced
- Website (`packages/website`): Home, Docs, Contribute, Community — production server answers `GET /` (this was the Coolify `Cannot GET /` failure)
- Website: real paths, sitemap accepted by Google. Repo now has stronger titles, a home FAQ, and richer structured data (not live until you deploy)
## See it yourself — website on Coolify
## See it yourself
1. Pull/push this change, then in Coolify set **Base Directory** to `packages/website` (no leading slash).
2. Turn **Is it a static site?** **off**.
3. Either **Build Pack: Dockerfile** and port **80**, or **Nixpacks** and port **3000**.
4. Redeploy, then open the domain — you should see the LexAI home page, not `Cannot GET /`.
5. Docs live at `https://your-domain/#/docs`.
Local check: `npm run website:build` then `npm run website:start` and visit http://127.0.0.1:3000/
1. Merge/push this change, Coolify on **main**, redeploy `packages/website`.
2. Home should end with an FAQ. Docs should show Home / Docs crumbs.
3. Search Console → URL inspection → request indexing for `https://lexai.juankibin.space/` and `/docs`.
## Waiting on you
| # | Decision | Options (recommended bold) | What it unblocks |
| --- | --- | --- | --- |
| 1 | Redeploy the Coolify website with the settings above | **Dockerfile + port 80** / Nixpacks + port 3000 | public LexAI site |
| 2 | Confirm Cursor mid/strong model picker IDs | **Keep written IDs** / send exact picker strings | reliable Task dispatch |
| 1 | Deploy this SEO pass to production | **Redeploy Coolify from main** / wait | Google seeing the new titles/FAQ |
| 2 | Request indexing for Home and Docs | **Do after deploy** / later | Faster first index |
| 3 | Grammarly reference screenshots? | **Later** / capture into `docs/reference/` | Chrome UX gauntlet |
| 4 | Publish VS Code extension to Marketplace? | **Stay local for now** / set publisher + publish | public install |
## Next up — proceeds without you
- Optional: VS Code Prompt Builder / style quick-pick / Marketplace packaging when you ask
- Rankings take days to weeks; no extra code until the new HTML is live

View File

@@ -8,6 +8,7 @@ _No active task contracts._
## Done (recent)
- **T-WEB-SEO-01** — Google indexing prep for `packages/website` (path URLs, robots/sitemap, unique titles, 404) (2026-09-03).
- **T-VSCODE-01** — VS Code LexAI v1 (`packages/vscode`): native commands/menus, Secret Storage, shared `@lib` via esbuild; verified with `vscode:typecheck` + `vscode:build` + root typecheck/tests (2026-08-13).
## Task contract format

View File

@@ -0,0 +1,47 @@
# Website indexing (Google)
## User + job
Someone searching for a BYO-LLM writing/code assist tool finds LexAI and lands on the matching page (home, docs, contribute, or community). Success: the snippet title and URL match the page they open.
## Flow
Search result → HTTPS path (`/`, `/docs`, `/contribute`, `/community`) → page. Old `#/docs` links redirect to `/docs` with no extra click. Unknown paths: one screen, link home. Target: 0 taps after the SERP click.
## Screen states
| State | What |
| --- | --- |
| Success | Existing Home / Docs / Contribute / Community; browser tab title matches the route |
| Not found | H1 “Page not found”, one sentence, primary “Home”, secondary “Docs” |
| Loading / empty / offline | Unchanged marketing pages (static). No new loading chrome |
## Components
Reuse `SiteHeader`, `SiteFooter`, `.page` / `.docs-content` / `.btn`. No new design-system pattern except **Not found** (same as `.page`).
## Copy (en)
- Title (404): Page not found
- Body: That address is not a LexAI page. Check the URL or go back home.
- Primary: Home
- Secondary: Docs
Route titles/descriptions live in `src/seo.ts` (not on-page H1s).
## Accessibility
404 is a real `h1`; both actions are text links/buttons ≥ 40px; focus order follows visual order.
## Acceptance
1. `/docs`, `/contribute`, `/community` are path URLs (not `#/…`).
2. `/robots.txt` allows `/` and names the sitemap.
3. `/sitemap.xml` lists the four indexable URLs on `https://lexai.juankibin.space`.
4. Each of those URLs has a unique `<title>` and meta description in the HTML file Google fetches.
5. Unknown path shows the 404 copy; crawlers get HTTP 404.
6. `#/docs` becomes `/docs`.
## Out of scope
Search Console verification token, sitemap ping, paid ads, i18n locales beyond en.

View File

@@ -0,0 +1,30 @@
# Home FAQ (SEO)
## User + job
Someone comparing LexAI to Grammarly or Copilot gets a direct answer without leaving Home. Success: they install or open Docs.
## Flow
Home → FAQ section → expand one question → Docs or store CTA already on the page. Target: read in place (0 extra pages).
## Screen states
| State | What |
| --- | --- |
| Success | Five `<details>`; first collapsed |
| Empty / loading / error / offline | Static copy; no extra states |
## Copy
Questions and answers live in `packages/website/src/faq.json` only (visible text = JSON-LD).
## Accessibility
`summary` min-height 44px; native details/summary keyboard. Breadcrumbs: `nav aria-label="Breadcrumb"` on Docs, Contribute, Community.
## Acceptance
1. Home `#faq` shows the five Q&As from `faq.json`.
2. Built `dist/index.html` includes `FAQPage` JSON-LD with the same questions.
3. Docs/Contribute/Community show Home / current breadcrumbs.

View File

@@ -28,7 +28,7 @@ Preview production build locally: `npm run website:preview` (Vite, localhost onl
Production start (what Coolify should run): `npm start` — serves `dist/` on `0.0.0.0:$PORT` (default 3000).
Uses HashRouter so deep links work even without SPA rewrite rules. The production server still maps unknown paths to `index.html`.
Uses BrowserRouter. Production nginx/Node serve `/docs`, `/contribute`, and `/community` as real paths (legacy `#/docs` links rewrite on load). Unknown paths return HTTP 404.
## Deploy on Coolify
@@ -44,7 +44,7 @@ This package is a static Vite build. No LexAI API keys or env vars. Do not add `
4. Branch: `dev` or `main`.
5. **Base Directory:** `packages/website` — no leading slash. Root `npm run build` builds Chrome, not this site.
6. Optional **Watch Paths:** `packages/website`.
7. Set the domain, deploy, open `https://your-domain/` (docs: `https://your-domain/#/docs`).
7. Set the domain, deploy, open `https://lexai.juankibin.space/` (docs: `/docs`).
### Option A — Dockerfile (recommended)
@@ -66,3 +66,13 @@ Do **not** tick "Is it a static site?" with Publish Directory `/dist`. The leadi
If you already created the resource: uncheck static site, set Base Directory and port as above, then Redeploy (no need to recreate the app).
Auto-deploy: Coolify resource → **Webhooks** → copy the URL + secret into Gitea **Settings → Webhooks** (push events).
## Google Search
After the site is live on `https://lexai.juankibin.space/`:
1. Open [Google Search Console](https://search.google.com/search-console), add a URL-prefix property for that origin, and verify (HTML file or DNS).
2. Sitemaps → submit `https://lexai.juankibin.space/sitemap.xml`.
3. URL inspection → request indexing for `/` and `/docs`.
Crawlers should see `Allow: /` at `/robots.txt` and unique titles on `/`, `/docs`, `/contribute`, and `/community`.

View File

@@ -30,7 +30,7 @@ Pick a pattern (role, chain-of-thought, and more), set persona and output format
Same BYO-key model: your API key, your provider, no LexAI servers.
Try it: https://lexai.juankibin.space/
Docs: https://lexai.juankibin.space/#/docs
Docs: https://lexai.juankibin.space/docs
#PromptEngineering #AI #OpenSource #DeveloperTools #BYOLLM
@@ -189,7 +189,7 @@ LexAI is an open-source toolkit for people who already have an LLM API key — a
No LexAI backend, account, or telemetry. Keys stay on-device (encrypted in Chrome, Secret Storage in VS Code).
Site: https://lexai.juankibin.space/
Docs: https://lexai.juankibin.space/#/docs
Docs: https://lexai.juankibin.space/docs
Repo: https://git.juankibin.space/kibin/LexAI
Chrome: https://chromewebstore.google.com/detail/bagpcheidbkfgijnnmolnkgagibbjfnk
VS Code: https://marketplace.visualstudio.com/items?itemName=JuanKibin.lexai-vscode

View File

@@ -3,9 +3,34 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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 — Grammarly-style writing help with your own API key"
/>
<meta
property="og:description"
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 — Grammarly-style writing help with your own API key"
/>
<meta
name="twitter:description"
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" />
@@ -14,7 +39,28 @@
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<title>LexAI — BYO-LLM writing & code assist</title>
<title>LexAI — Grammarly-style writing help with your own API key</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebSite",
"name": "LexAI",
"url": "https://lexai.juankibin.space/",
"description": "Bring-your-own-LLM writing and code assist for Chrome, VS Code, and the CLI."
},
{
"@type": "SoftwareApplication",
"name": "LexAI",
"applicationCategory": "BrowserApplication",
"operatingSystem": "Chrome, VS Code, Windows, macOS, Linux",
"url": "https://lexai.juankibin.space/",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
}
]
}
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -0,0 +1,152 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(fileURLToPath(import.meta.url));
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'));
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(
/(<meta\s+name="description"\s+content=")[^"]*(")/,
`$1${description}$2`,
);
out = out.replace(/(<meta\s+name="robots"\s+content=")[^"]*(")/, `$1${robots}$2`);
out = out.replace(/(<meta\s+property="og:title"\s+content=")[^"]*(")/, `$1${title}$2`);
out = out.replace(
/(<meta\s+property="og:description"\s+content=")[^"]*(")/,
`$1${description}$2`,
);
out = out.replace(/(<meta\s+property="og:url"\s+content=")[^"]*(")/, `$1${url}$2`);
out = out.replace(/(<meta\s+name="twitter:title"\s+content=")[^"]*(")/, `$1${title}$2`);
out = out.replace(
/(<meta\s+name="twitter:description"\s+content=")[^"]*(")/,
`$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;
}
const template = readFileSync(distHtml, 'utf8');
if (!template.includes('rel="canonical"') || !template.includes('og:title')) {
console.error('dist/index.html is missing SEO tags — check index.html');
process.exit(1);
}
const written = [];
for (const route of routes) {
const url = route.path === '/' ? `${origin}/` : `${origin}${route.path}`;
const file = route.path === '/' ? 'index.html' : `${route.path.replace(/^\//, '')}/index.html`;
const dest = join(root, 'dist', file);
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(
dest,
apply(template, {
title: route.title,
description: route.description,
url,
robots: 'index, follow',
ld: jsonLd(route),
}),
);
written.push(file);
}
writeFileSync(
join(root, 'dist', '404.html'),
apply(template, {
title: 'Page not found — LexAI',
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

@@ -10,7 +10,28 @@ server {
return 200 'ok';
}
location / {
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;
}
location ~ ^/(docs|contribute|community)/?$ {
try_files $uri $uri/ /index.html;
}
error_page 404 =404 /404.html;
location = /404.html {
internal;
}
location / {
try_files $uri $uri/ =404;
}
}

View File

@@ -8,7 +8,7 @@
},
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"build": "tsc --noEmit && vite build && node inject-seo.mjs",
"preview": "vite preview",
"start": "node server.mjs",
"typecheck": "tsc --noEmit"

View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://lexai.juankibin.space/sitemap.xml

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://lexai.juankibin.space/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://lexai.juankibin.space/docs</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://lexai.juankibin.space/contribute</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
<url>
<loc>https://lexai.juankibin.space/community</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
</urlset>

View File

@@ -20,8 +20,21 @@ const MIME = {
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.xml': 'application/xml; charset=utf-8',
};
const SPA_ROUTES = new Set(['/', '/docs', '/contribute', '/community']);
function requestPath(urlPath) {
const decoded = decodeURIComponent((urlPath ?? '/').split('?')[0] || '/');
if (decoded.length > 1 && decoded.endsWith('/')) return decoded.slice(0, -1) || '/';
return decoded || '/';
}
function isSpaRoute(urlPath) {
return SPA_ROUTES.has(requestPath(urlPath));
}
function fileUnderRoot(urlPath) {
const decoded = decodeURIComponent((urlPath ?? '/').split('?')[0] || '/');
const relative = decoded.replace(/^\/+/, '').replaceAll('/', sep);
@@ -54,8 +67,14 @@ const server = createServer((req, res) => {
}
let file = candidate;
if (!existsSync(file) || statSync(file).isDirectory()) {
file = join(root, 'index.html');
let status = 200;
if (existsSync(file) && statSync(file).isDirectory()) {
const nested = join(file, 'index.html');
file = existsSync(nested) ? nested : join(root, 'index.html');
} else if (!existsSync(file)) {
const notFound = join(root, '404.html');
file = existsSync(notFound) && !isSpaRoute(req.url ?? '/') ? notFound : join(root, 'index.html');
if (!isSpaRoute(req.url ?? '/')) status = 404;
}
if (!existsSync(file)) {
send(res, 500, 'dist/ missing — run npm run build before start');
@@ -63,7 +82,12 @@ const server = createServer((req, res) => {
}
const type = MIME[extname(file).toLowerCase()] ?? 'application/octet-stream';
res.writeHead(200, { '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

@@ -1,14 +1,17 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { Route, Routes } from 'react-router-dom';
import { SeoHead } from './components/SeoHead';
import { SiteHeader } from './components/SiteHeader';
import { SiteFooter } from './components/SiteFooter';
import { HomePage } from './pages/HomePage';
import { DocsPage } from './pages/DocsPage';
import { ContributePage } from './pages/ContributePage';
import { CommunityPage } from './pages/CommunityPage';
import { NotFoundPage } from './pages/NotFoundPage';
export default function App() {
return (
<div className="shell">
<SeoHead />
<SiteHeader />
<main>
<Routes>
@@ -16,7 +19,7 @@ export default function App() {
<Route path="/docs" element={<DocsPage />} />
<Route path="/contribute" element={<ContributePage />} />
<Route path="/community" element={<CommunityPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</main>
<SiteFooter />

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

@@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
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;
if (!el) {
el = document.createElement('meta');
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
document.head.appendChild(el);
}
el.setAttribute('content', content);
}
function upsertCanonical(href: string) {
let el = document.head.querySelector('link[rel="canonical"]') as HTMLLinkElement | null;
if (!el) {
el = document.createElement('link');
el.rel = 'canonical';
document.head.appendChild(el);
}
el.href = href;
}
export function SeoHead() {
const { pathname } = useLocation();
useEffect(() => {
const seo = seoForPath(pathname);
const url = seo.index ? canonicalUrl(seo.path) : `${SITE_ORIGIN}${pathname}`;
document.title = seo.title;
upsertMeta('meta[name="description"]', { name: 'description' }, seo.description);
upsertMeta(
'meta[name="robots"]',
{ name: 'robots' },
seo.index ? 'index, follow' : 'noindex, follow',
);
upsertCanonical(url);
upsertMeta('meta[property="og:title"]', { property: 'og:title' }, seo.title);
upsertMeta('meta[property="og:description"]', { property: 'og:description' }, seo.description);
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,13 +1,19 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { HashRouter } from 'react-router-dom';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles.css';
const hash = window.location.hash;
if (hash.startsWith('#/')) {
const path = hash.slice(1) || '/';
window.history.replaceState(null, '', path + window.location.search);
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<HashRouter>
<BrowserRouter>
<App />
</HashRouter>
</BrowserRouter>
</StrictMode>,
);

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

@@ -0,0 +1,20 @@
import { Link } from 'react-router-dom';
export function NotFoundPage() {
return (
<div className="wrap page">
<article className="docs-content">
<h1>Page not found</h1>
<p className="intro">That address is not a LexAI page. Check the URL or go back home.</p>
<div className="cta-row">
<Link className="btn btn-primary" to="/">
Home
</Link>
<Link className="btn btn-ghost" to="/docs">
Docs
</Link>
</div>
</article>
</div>
);
}

View File

@@ -0,0 +1,25 @@
{
"origin": "https://lexai.juankibin.space",
"routes": [
{
"path": "/",
"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": "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 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": "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

@@ -0,0 +1,86 @@
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;
export type SeoRoute = {
path: string;
title: string;
description: string;
index: boolean;
};
export const INDEXABLE_ROUTES: SeoRoute[] = routesFile.routes.map((r) => ({
...r,
index: true,
}));
export const NOT_FOUND_SEO: SeoRoute = {
path: '',
title: 'Page not found — LexAI',
description: 'That address is not a LexAI page.',
index: false,
};
const byPath = new Map(INDEXABLE_ROUTES.map((r) => [r.path, r]));
export function seoForPath(pathname: string): SeoRoute {
const normalized = pathname.replace(/\/+$/, '') || '/';
return byPath.get(normalized) ?? NOT_FOUND_SEO;
}
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;

View File

@@ -7,6 +7,7 @@
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,