From 3150e162486c227ea1f668ea60bcb49f56e12be5 Mon Sep 17 00:00:00 2001 From: john kevin asprec Date: Thu, 3 Sep 2026 21:40:55 +0800 Subject: [PATCH] google search index --- docs/DECISIONS.md | 10 +++ docs/DESIGN_SYSTEM.md | 1 + docs/HANDOFF.md | 9 ++- docs/MEMORY.md | 2 +- docs/PROGRESS.md | 17 ++--- docs/TASKS.md | 1 + docs/design/website-indexing.md | 47 ++++++++++++++ packages/website/README.md | 14 ++++- packages/website/SOCIAL_POSTS.md | 4 +- packages/website/index.html | 40 +++++++++++- packages/website/inject-seo.mjs | 69 +++++++++++++++++++++ packages/website/nginx.conf | 21 ++++++- packages/website/package.json | 2 +- packages/website/public/robots.txt | 4 ++ packages/website/public/sitemap.xml | 23 +++++++ packages/website/server.mjs | 25 +++++++- packages/website/src/App.tsx | 7 ++- packages/website/src/components/SeoHead.tsx | 47 ++++++++++++++ packages/website/src/main.tsx | 12 +++- packages/website/src/pages/NotFoundPage.tsx | 20 ++++++ packages/website/src/seo.routes.json | 25 ++++++++ packages/website/src/seo.ts | 34 ++++++++++ packages/website/tsconfig.json | 1 + 23 files changed, 406 insertions(+), 29 deletions(-) create mode 100644 docs/design/website-indexing.md create mode 100644 packages/website/inject-seo.mjs create mode 100644 packages/website/public/robots.txt create mode 100644 packages/website/public/sitemap.xml create mode 100644 packages/website/src/components/SeoHead.tsx create mode 100644 packages/website/src/pages/NotFoundPage.tsx create mode 100644 packages/website/src/seo.routes.json create mode 100644 packages/website/src/seo.ts diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index cd76f3b..1bc5e2c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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 diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 97eee41..e65ca29 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -31,3 +31,4 @@ 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 | diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index bc97f00..2b9cc75 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -4,8 +4,7 @@ ## 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:** Website prepared for Google indexing (path URLs, robots, sitemap, unique titles). +- **Shipped:** BrowserRouter; hash `#/…` rewrite; `public/robots.txt` + `sitemap.xml`; `src/seo.ts` + `SeoHead`; `inject-seo.mjs`; HTTP 404 + `NotFoundPage`; origin `https://lexai.juankibin.space`. +- **Verified:** `website:build` OK. Local `node server.mjs`: `/` `/docs` `/robots.txt` `/sitemap.xml` 200 (docs title unique; sitemap `application/xml`); `/no-such-page` 404. +- **Next smallest action:** Redeploy Coolify, then Search Console: verify property, submit sitemap, request index of `/` and `/docs`. diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 5d5bac8..8152222 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -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) | diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index be49449..aeed01b 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -2,21 +2,21 @@ > 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 - 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 (`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 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`. +4. Redeploy, then open `https://lexai.juankibin.space/` — docs at `/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/ @@ -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 | | --- | --- | --- | --- | -| 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 | -| 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 | +| 1 | Redeploy the Coolify website with the settings above | **Dockerfile + port 80** / Nixpacks + port 3000 | public LexAI site + crawlable URLs | +| 2 | Google Search Console: verify + submit sitemap | **Do this after redeploy** / later | Google indexing | +| 3 | Confirm Cursor mid/strong model picker IDs | **Keep written IDs** / send exact picker strings | reliable Task dispatch | +| 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 diff --git a/docs/TASKS.md b/docs/TASKS.md index 8792d8b..a038a04 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -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 diff --git a/docs/design/website-indexing.md b/docs/design/website-indexing.md new file mode 100644 index 0000000..fe37161 --- /dev/null +++ b/docs/design/website-indexing.md @@ -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 `` 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. diff --git a/packages/website/README.md b/packages/website/README.md index 2daee74..3d5dde0 100644 --- a/packages/website/README.md +++ b/packages/website/README.md @@ -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`. diff --git a/packages/website/SOCIAL_POSTS.md b/packages/website/SOCIAL_POSTS.md index 6377299..3a355b3 100644 --- a/packages/website/SOCIAL_POSTS.md +++ b/packages/website/SOCIAL_POSTS.md @@ -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 diff --git a/packages/website/index.html b/packages/website/index.html index 956150c..323021e 100644 --- a/packages/website/index.html +++ b/packages/website/index.html @@ -3,10 +3,27 @@ <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." /> + <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="preconnect" href="https://fonts.googleapis.com" /> <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" rel="stylesheet" /> - <title>LexAI — BYO-LLM writing & code assist + LexAI — BYO-LLM writing & code assist +
diff --git a/packages/website/inject-seo.mjs b/packages/website/inject-seo.mjs new file mode 100644 index 0000000..0e2d87c --- /dev/null +++ b/packages/website/inject-seo.mjs @@ -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(/[\s\S]*?<\/title>/, `<title>${title}`); + out = out.replace( + /( + + + https://lexai.juankibin.space/ + weekly + 1.0 + + + https://lexai.juankibin.space/docs + weekly + 0.8 + + + https://lexai.juankibin.space/contribute + monthly + 0.5 + + + https://lexai.juankibin.space/community + monthly + 0.5 + + diff --git a/packages/website/server.mjs b/packages/website/server.mjs index 118f064..dd0c15d 100644 --- a/packages/website/server.mjs +++ b/packages/website/server.mjs @@ -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,7 @@ const server = createServer((req, res) => { } 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') { res.end(); return; diff --git a/packages/website/src/App.tsx b/packages/website/src/App.tsx index 1ba9445..07a6c5e 100644 --- a/packages/website/src/App.tsx +++ b/packages/website/src/App.tsx @@ -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 (
+
@@ -16,7 +19,7 @@ export default function App() { } /> } /> } /> - } /> + } />
diff --git a/packages/website/src/components/SeoHead.tsx b/packages/website/src/components/SeoHead.tsx new file mode 100644 index 0000000..e9fc7dc --- /dev/null +++ b/packages/website/src/components/SeoHead.tsx @@ -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, 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; +} diff --git a/packages/website/src/main.tsx b/packages/website/src/main.tsx index 224bfab..603ad7c 100644 --- a/packages/website/src/main.tsx +++ b/packages/website/src/main.tsx @@ -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( - + - + , ); diff --git a/packages/website/src/pages/NotFoundPage.tsx b/packages/website/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..fd54208 --- /dev/null +++ b/packages/website/src/pages/NotFoundPage.tsx @@ -0,0 +1,20 @@ +import { Link } from 'react-router-dom'; + +export function NotFoundPage() { + return ( +
+
+

Page not found

+

That address is not a LexAI page. Check the URL or go back home.

+
+ + Home + + + Docs + +
+
+
+ ); +} diff --git a/packages/website/src/seo.routes.json b/packages/website/src/seo.routes.json new file mode 100644 index 0000000..201748c --- /dev/null +++ b/packages/website/src/seo.routes.json @@ -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." + } + ] +} diff --git a/packages/website/src/seo.ts b/packages/website/src/seo.ts new file mode 100644 index 0000000..0fe551e --- /dev/null +++ b/packages/website/src/seo.ts @@ -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}`}`; +} diff --git a/packages/website/tsconfig.json b/packages/website/tsconfig.json index 6b7b651..c83d996 100644 --- a/packages/website/tsconfig.json +++ b/packages/website/tsconfig.json @@ -7,6 +7,7 @@ "skipLibCheck": true, "moduleResolution": "Bundler", "allowImportingTsExtensions": true, + "resolveJsonModule": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, -- 2.43.0