Compare commits
2 Commits
85f0fc0dc5
...
1788b2b5be
| Author | SHA1 | Date | |
|---|---|---|---|
| 1788b2b5be | |||
|
|
3150e16248 |
@@ -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
|
||||||
|
|||||||
@@ -31,3 +31,4 @@ 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 |
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
- **Outcome:** Coolify `Cannot GET /` fixed — website now has a production static server and a Dockerfile.
|
- **Outcome:** Website prepared for Google indexing (path URLs, robots, sitemap, unique titles).
|
||||||
- **Cause:** Nixpacks built Vite `dist/` but had no `start` command; README also told Coolify to publish `/dist` (absolute empty path).
|
- **Shipped:** BrowserRouter; hash `#/…` rewrite; `public/robots.txt` + `sitemap.xml`; `src/seo.ts` + `SeoHead`; `inject-seo.mjs`; HTTP 404 + `NotFoundPage`; origin `https://lexai.juankibin.space`.
|
||||||
- **Shipped:** `packages/website/server.mjs` (`npm start` on `0.0.0.0:$PORT`); `Dockerfile` + `nginx.conf`; `nixpacks.toml` `[start]`; Vite `base` is `/`.
|
- **Verified:** `website:build` OK. Local `node server.mjs`: `/` `/docs` `/robots.txt` `/sitemap.xml` 200 (docs title unique; sitemap `application/xml`); `/no-such-page` 404.
|
||||||
- **Verified:** `website:typecheck` + `website:build`; local `node server.mjs` → GET `/` 200 (LexAI HTML), `/healthz` 200, JS asset `text/javascript`.
|
- **Next smallest action:** Redeploy Coolify, then Search Console: verify property, submit sitemap, request index of `/` and `/docs`.
|
||||||
- **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.
|
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -2,21 +2,21 @@
|
|||||||
|
|
||||||
> 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-03 · **Overall:** Website is ready for Google Search; needs a Coolify redeploy, then Search Console
|
||||||
|
|
||||||
## 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 (`packages/website`): Home, Docs, Contribute, Community at real paths (`/docs`, not `/#/docs`). `/robots.txt` and `/sitemap.xml` are in the build.
|
||||||
|
|
||||||
## See it yourself — website on Coolify
|
## See it yourself — website on Coolify
|
||||||
|
|
||||||
1. Pull/push this change, then in Coolify set **Base Directory** to `packages/website` (no leading slash).
|
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**.
|
2. Turn **Is it a static site?** **off**.
|
||||||
3. Either **Build Pack: Dockerfile** and port **80**, or **Nixpacks** and port **3000**.
|
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 /`.
|
4. Redeploy, then open `https://lexai.juankibin.space/` — docs at `/docs`.
|
||||||
5. Docs live at `https://your-domain/#/docs`.
|
5. In [Google Search Console](https://search.google.com/search-console): add that URL, verify ownership, submit `https://lexai.juankibin.space/sitemap.xml`, request indexing for Home and Docs.
|
||||||
|
|
||||||
Local check: `npm run website:build` then `npm run website:start` and visit http://127.0.0.1:3000/
|
Local check: `npm run website:build` then `npm run website:start` and visit http://127.0.0.1:3000/
|
||||||
|
|
||||||
@@ -24,10 +24,11 @@ Local check: `npm run website:build` then `npm run website:start` and visit http
|
|||||||
|
|
||||||
| # | 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 | Redeploy the Coolify website with the settings above | **Dockerfile + port 80** / Nixpacks + port 3000 | public LexAI site + crawlable URLs |
|
||||||
| 2 | Confirm Cursor mid/strong model picker IDs | **Keep written IDs** / send exact picker strings | reliable Task dispatch |
|
| 2 | Google Search Console: verify + submit sitemap | **Do this after redeploy** / later | Google indexing |
|
||||||
| 3 | Grammarly reference screenshots? | **Later** / capture into `docs/reference/` | Chrome UX gauntlet |
|
| 3 | Confirm Cursor mid/strong model picker IDs | **Keep written IDs** / send exact picker strings | reliable Task dispatch |
|
||||||
| 4 | Publish VS Code extension to Marketplace? | **Stay local for now** / set publisher + publish | public install |
|
| 4 | Grammarly reference screenshots? | **Later** / capture into `docs/reference/` | Chrome UX gauntlet |
|
||||||
|
| 5 | 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
47
docs/design/website-indexing.md
Normal file
47
docs/design/website-indexing.md
Normal 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.
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -3,10 +3,27 @@
|
|||||||
<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="LexAI — bring-your-own-LLM writing and code assist. Chrome, VS Code, and CLI. No LexAI servers, no subscription."
|
||||||
/>
|
/>
|
||||||
|
<link rel="canonical" href="https://lexai.juankibin.space/" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="LexAI" />
|
||||||
|
<meta property="og:title" content="LexAI — BYO-LLM writing & code assist" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="LexAI — bring-your-own-LLM writing and code assist. Chrome, VS Code, and CLI. No LexAI servers, no subscription."
|
||||||
|
/>
|
||||||
|
<meta property="og:url" content="https://lexai.juankibin.space/" />
|
||||||
|
<meta property="og:image" content="https://lexai.juankibin.space/icon.svg" />
|
||||||
|
<meta name="twitter:card" content="summary" />
|
||||||
|
<meta name="twitter:title" content="LexAI — BYO-LLM writing & code assist" />
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="LexAI — bring-your-own-LLM writing and code assist. Chrome, VS Code, and CLI. No LexAI servers, no 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" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
@@ -14,7 +31,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 — BYO-LLM writing & code assist</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>
|
||||||
|
|||||||
69
packages/website/inject-seo.mjs
Normal file
69
packages/website/inject-seo.mjs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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'),
|
||||||
|
);
|
||||||
|
|
||||||
|
function apply(html, { title, description, url, robots }) {
|
||||||
|
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`);
|
||||||
|
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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
written.push('404.html');
|
||||||
|
|
||||||
|
console.log('SEO HTML written for', written.join(', '));
|
||||||
@@ -10,7 +10,26 @@ server {
|
|||||||
return 200 'ok';
|
return 200 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
location / {
|
location = /robots.txt {
|
||||||
|
try_files /robots.txt =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /sitemap.xml {
|
||||||
|
default_type application/xml;
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
4
packages/website/public/robots.txt
Normal file
4
packages/website/public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://lexai.juankibin.space/sitemap.xml
|
||||||
23
packages/website/public/sitemap.xml
Normal file
23
packages/website/public/sitemap.xml
Normal 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>
|
||||||
@@ -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,7 @@ 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 });
|
res.writeHead(status, { 'Content-Type': type });
|
||||||
if (req.method === 'HEAD') {
|
if (req.method === 'HEAD') {
|
||||||
res.end();
|
res.end();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
47
packages/website/src/components/SeoHead.tsx
Normal file
47
packages/website/src/components/SeoHead.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { canonicalUrl, 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);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
20
packages/website/src/pages/NotFoundPage.tsx
Normal file
20
packages/website/src/pages/NotFoundPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
packages/website/src/seo.routes.json
Normal file
25
packages/website/src/seo.routes.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"origin": "https://lexai.juankibin.space",
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/contribute",
|
||||||
|
"title": "Contribute — LexAI",
|
||||||
|
"description": "How to report bugs, set up the LexAI monorepo, and send focused pull requests."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "/community",
|
||||||
|
"title": "Community — LexAI",
|
||||||
|
"description": "LexAI source, issues, Chrome Web Store, VS Code Marketplace, and Code of Conduct."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
34
packages/website/src/seo.ts
Normal file
34
packages/website/src/seo.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import routesFile from './seo.routes.json';
|
||||||
|
|
||||||
|
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}`}`;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user