8 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
85f0fc0dc5 Merge pull request 'dev' (#4) from dev into main
All checks were successful
CI — Website / Website — typecheck, build (push) Successful in 58s
Reviewed-on: #4
2026-08-18 00:55:46 +00:00
fb9c13fbd5 Merge pull request 'fix: serve the website dist so Coolify answers GET /' (#3) from dev into main
All checks were successful
CI — Website / Website — typecheck, build (push) Successful in 1m3s
Reviewed-on: #3
2026-08-17 22:12:11 +00:00
9895244535 Merge pull request 'dev' (#2) from dev into main
Some checks failed
CI — Website / Website — typecheck, build (push) Has been cancelled
CI — VS Code / VS Code — typecheck, build (push) Has been cancelled
CI — CLI / CLI — typecheck, build, smoke (push) Has been cancelled
CI — Chrome / Chrome — typecheck, test, build (push) Failing after 1m53s
Reviewed-on: #2
2026-08-13 12:30:27 +00:00
df0bcae250 Merge pull request 'dev' (#1) from dev into main
Some checks failed
CI — Test & Build / Test & Build (push) Has been cancelled
Reviewed-on: #1
2026-08-13 11:23:25 +00: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 ## 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 ### D-2026-08-13-01 — VS Code port: shared `src/lib` + native v1 UX
- **Status:** accepted - **Status:** accepted

View File

@@ -31,3 +31,6 @@ Tap targets ≥ 40px; body text contrast AA.
| Feature list | Two-column hairline rows | | Feature list | Two-column hairline rows |
| PackageCard | Chrome / VS Code / CLI | | PackageCard | Chrome / VS Code / CLI |
| DocsSection | Package docs blocks | | 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 ## Current state
- **Outcome:** Coolify `Cannot GET /` fixed — website now has a production static server and a Dockerfile. - **Outcome:** Sitemap accepted in GSC. SEO pass: intent titles, home FAQ + FAQPage JSON-LD, breadcrumbs, lastmod sitemap, 5-min cache on robots/sitemap.
- **Cause:** Nixpacks built Vite `dist/` but had no `start` command; README also told Coolify to publish `/dist` (absolute empty path). - **Verified:** `website:typecheck` + `website:build` OK (inject wrote route HTML + sitemap.xml).
- **Shipped:** `packages/website/server.mjs` (`npm start` on `0.0.0.0:$PORT`); `Dockerfile` + `nginx.conf`; `nixpacks.toml` `[start]`; Vite `base` is `/`. - **Next smallest action:** Merge/deploy this to `main`, purge Cloudflare `/robots.txt`, then in GSC request indexing for `/` and `/docs`.
- **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.

View File

@@ -32,7 +32,7 @@
| Shared lib | `src/lib/` | | Shared lib | `src/lib/` |
| VS Code | `packages/vscode/` | | VS Code | `packages/vscode/` |
| CLI | `packages/cli/` | | 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` | | OSS meta | root `README.md`, `LICENSE`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md` |
| Issue/PR templates | `.gitea/ISSUE_TEMPLATE/`, `.gitea/PULL_REQUEST_TEMPLATE.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) | | 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. > 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 ## What works now
- Chrome extension: select on any page → AI action → Replace/Copy - Chrome extension: select on any page → AI action → Replace/Copy
- VS Code extension: select in editor → LexAI context menu / Command Palette → selection replaced - 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). 1. Merge/push this change, Coolify on **main**, redeploy `packages/website`.
2. Turn **Is it a static site?** **off**. 2. Home should end with an FAQ. Docs should show Home / Docs crumbs.
3. Either **Build Pack: Dockerfile** and port **80**, or **Nixpacks** and port **3000**. 3. Search Console → URL inspection → request indexing for `https://lexai.juankibin.space/` and `/docs`.
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/
## Waiting on you ## Waiting on you
| # | Decision | Options (recommended bold) | What it unblocks | | # | 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 | | 1 | Deploy this SEO pass to production | **Redeploy Coolify from main** / wait | Google seeing the new titles/FAQ |
| 2 | Confirm Cursor mid/strong model picker IDs | **Keep written IDs** / send exact picker strings | reliable Task dispatch | | 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 | | 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 ## 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) ## 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). - **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 ## 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). 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 ## 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`. 4. Branch: `dev` or `main`.
5. **Base Directory:** `packages/website` — no leading slash. Root `npm run build` builds Chrome, not this site. 5. **Base Directory:** `packages/website` — no leading slash. Root `npm run build` builds Chrome, not this site.
6. Optional **Watch Paths:** `packages/website`. 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) ### 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). 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). 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. Same BYO-key model: your API key, your provider, no LexAI servers.
Try it: https://lexai.juankibin.space/ Try it: https://lexai.juankibin.space/
Docs: https://lexai.juankibin.space/#/docs Docs: https://lexai.juankibin.space/docs
#PromptEngineering #AI #OpenSource #DeveloperTools #BYOLLM #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). No LexAI backend, account, or telemetry. Keys stay on-device (encrypted in Chrome, Secret Storage in VS Code).
Site: https://lexai.juankibin.space/ Site: https://lexai.juankibin.space/
Docs: https://lexai.juankibin.space/#/docs Docs: https://lexai.juankibin.space/docs
Repo: https://git.juankibin.space/kibin/LexAI Repo: https://git.juankibin.space/kibin/LexAI
Chrome: https://chromewebstore.google.com/detail/bagpcheidbkfgijnnmolnkgagibbjfnk Chrome: https://chromewebstore.google.com/detail/bagpcheidbkfgijnnmolnkgagibbjfnk
VS Code: https://marketplace.visualstudio.com/items?itemName=JuanKibin.lexai-vscode VS Code: https://marketplace.visualstudio.com/items?itemName=JuanKibin.lexai-vscode

View File

@@ -3,9 +3,34 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="index, follow" />
<meta <meta
name="description" 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="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <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" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
rel="stylesheet" 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> </head>
<body> <body>
<div id="root"></div> <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'; 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; 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": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build && node inject-seo.mjs",
"preview": "vite preview", "preview": "vite preview",
"start": "node server.mjs", "start": "node server.mjs",
"typecheck": "tsc --noEmit" "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', '.webp': 'image/webp',
'.woff': 'font/woff', '.woff': 'font/woff',
'.woff2': 'font/woff2', '.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) { function fileUnderRoot(urlPath) {
const decoded = decodeURIComponent((urlPath ?? '/').split('?')[0] || '/'); const decoded = decodeURIComponent((urlPath ?? '/').split('?')[0] || '/');
const relative = decoded.replace(/^\/+/, '').replaceAll('/', sep); const relative = decoded.replace(/^\/+/, '').replaceAll('/', sep);
@@ -54,8 +67,14 @@ const server = createServer((req, res) => {
} }
let file = candidate; let file = candidate;
if (!existsSync(file) || statSync(file).isDirectory()) { let status = 200;
file = join(root, 'index.html'); 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)) { if (!existsSync(file)) {
send(res, 500, 'dist/ missing — run npm run build before start'); 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'; 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') { if (req.method === 'HEAD') {
res.end(); res.end();
return; 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 { SiteHeader } from './components/SiteHeader';
import { SiteFooter } from './components/SiteFooter'; import { SiteFooter } from './components/SiteFooter';
import { HomePage } from './pages/HomePage'; import { HomePage } from './pages/HomePage';
import { DocsPage } from './pages/DocsPage'; import { DocsPage } from './pages/DocsPage';
import { ContributePage } from './pages/ContributePage'; import { ContributePage } from './pages/ContributePage';
import { CommunityPage } from './pages/CommunityPage'; import { CommunityPage } from './pages/CommunityPage';
import { NotFoundPage } from './pages/NotFoundPage';
export default function App() { export default function App() {
return ( return (
<div className="shell"> <div className="shell">
<SeoHead />
<SiteHeader /> <SiteHeader />
<main> <main>
<Routes> <Routes>
@@ -16,7 +19,7 @@ export default function App() {
<Route path="/docs" element={<DocsPage />} /> <Route path="/docs" element={<DocsPage />} />
<Route path="/contribute" element={<ContributePage />} /> <Route path="/contribute" element={<ContributePage />} />
<Route path="/community" element={<CommunityPage />} /> <Route path="/community" element={<CommunityPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<NotFoundPage />} />
</Routes> </Routes>
</main> </main>
<SiteFooter /> <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> <p style={{ margin: 0 }}>© {new Date().getFullYear()} LexAI · MIT</p>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<Link to="/docs">Docs</Link> <Link to="/docs">Docs</Link>
<Link to={{ pathname: '/', hash: 'faq' }}>FAQ</Link>
<Link to="/contribute">Contribute</Link> <Link to="/contribute">Contribute</Link>
<Link to="/community">Community</Link> <Link to="/community">Community</Link>
<a href={REPO} target="_blank" rel="noreferrer"> <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 { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { HashRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import App from './App'; import App from './App';
import './styles.css'; 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( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<HashRouter> <BrowserRouter>
<App /> <App />
</HashRouter> </BrowserRouter>
</StrictMode>, </StrictMode>,
); );

View File

@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Breadcrumbs } from '../components/Breadcrumbs';
import { import {
CHROME_STORE, CHROME_STORE,
REPO, REPO,
@@ -12,6 +13,7 @@ export function CommunityPage() {
return ( return (
<div className="wrap page"> <div className="wrap page">
<article className="docs-content"> <article className="docs-content">
<Breadcrumbs items={[{ label: 'Home', to: '/' }, { label: 'Community' }]} />
<h1>Community</h1> <h1>Community</h1>
<p className="intro"> <p className="intro">
LexAI is maintained in the open. Use these links to install, report issues, and follow the 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'; import { REPO, REPO_CONDUCT, REPO_CONTRIBUTING } from '../links';
export function ContributePage() { export function ContributePage() {
return ( return (
<div className="wrap page"> <div className="wrap page">
<article className="docs-content"> <article className="docs-content">
<Breadcrumbs items={[{ label: 'Home', to: '/' }, { label: 'Contribute' }]} />
<h1>Contribute</h1> <h1>Contribute</h1>
<p className="intro"> <p className="intro">
LexAI is a monorepo: Chrome, VS Code, CLI, website, and a shared library. Please follow 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 { useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Breadcrumbs } from '../components/Breadcrumbs';
import { FREE_PROVIDERS } from '../freeProviders'; import { FREE_PROVIDERS } from '../freeProviders';
import { CHROME_STORE, REPO, VSCODE_MARKETPLACE } from '../links'; import { CHROME_STORE, REPO, VSCODE_MARKETPLACE } from '../links';
@@ -38,6 +39,7 @@ export function DocsPage() {
</aside> </aside>
<article className="docs-content"> <article className="docs-content">
<Breadcrumbs items={[{ label: 'Home', to: '/' }, { label: 'Docs' }]} />
<h1>Documentation</h1> <h1>Documentation</h1>
<p className="intro"> <p className="intro">
LexAI is an open-source, bring-your-own-LLM assistant. This page covers every package and 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 { Link, useNavigate } from 'react-router-dom';
import { LogoMark } from '../components/LogoMark'; import { LogoMark } from '../components/LogoMark';
import { FREE_PROVIDERS, FREE_PROVIDER_BADGES, LEXAI_FREE_HIGHLIGHTS } from '../freeProviders'; import { FREE_PROVIDERS, FREE_PROVIDER_BADGES, LEXAI_FREE_HIGHLIGHTS } from '../freeProviders';
import faq from '../faq.json';
import { CHROME_STORE, REPO, VSCODE_MARKETPLACE } from '../links'; import { CHROME_STORE, REPO, VSCODE_MARKETPLACE } from '../links';
function openDocsSection(navigate: ReturnType<typeof useNavigate>, id: string) { function openDocsSection(navigate: ReturnType<typeof useNavigate>, id: string) {
@@ -232,6 +233,24 @@ export function HomePage() {
</ul> </ul>
</div> </div>
</section> </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); 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 { .feature-list {
display: grid; display: grid;
gap: 0; gap: 0;

View File

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