website: Create website (#19)
* Add website * save * chore: validate website in CI and fix workers typing
This commit is contained in:
parent
75f9868500
commit
a0ef7412f2
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@ -36,3 +36,12 @@ jobs:
|
|||||||
|
|
||||||
- name: Run CI checks
|
- name: Run CI checks
|
||||||
run: pnpm run ci:check
|
run: pnpm run ci:check
|
||||||
|
|
||||||
|
- name: Install website dependencies
|
||||||
|
run: pnpm --dir web install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run website type checks
|
||||||
|
run: pnpm --dir web run types:check
|
||||||
|
|
||||||
|
- name: Build website
|
||||||
|
run: pnpm --dir web run build
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
"node_modules",
|
"node_modules",
|
||||||
"dist",
|
"dist",
|
||||||
".output",
|
".output",
|
||||||
|
"web",
|
||||||
"src/routeTree.gen.ts",
|
"src/routeTree.gen.ts",
|
||||||
"worker-configuration.d.ts"
|
"worker-configuration.d.ts"
|
||||||
],
|
],
|
||||||
|
|||||||
@ -7,3 +7,4 @@ dist/
|
|||||||
drizzle/
|
drizzle/
|
||||||
planning/
|
planning/
|
||||||
worker-configuration.d.ts
|
worker-configuration.d.ts
|
||||||
|
web/
|
||||||
|
|||||||
13
knip.jsonc
13
knip.jsonc
@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"ignoreBinaries": ["portless"],
|
|
||||||
"entry": [
|
"entry": [
|
||||||
// Detect Tanstack Start Routes
|
// Detect Tanstack Start Routes
|
||||||
"src/router.tsx",
|
"src/router.tsx",
|
||||||
@ -9,29 +8,20 @@
|
|||||||
// DB index re-exports schema for convenience
|
// DB index re-exports schema for convenience
|
||||||
"src/db/index.ts",
|
"src/db/index.ts",
|
||||||
],
|
],
|
||||||
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts"],
|
"project": ["**/*.{js,mjs,ts,tsx}", "!src/routeTree.gen.ts", "!web/**"],
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"drizzle-prod.config.ts",
|
"drizzle-prod.config.ts",
|
||||||
"src/client/features/keywords/utils.ts",
|
"src/client/features/keywords/utils.ts",
|
||||||
"src/client/hooks/useDomainSearchHistory.ts",
|
|
||||||
"src/client/hooks/useSearchHistory.ts",
|
"src/client/hooks/useSearchHistory.ts",
|
||||||
"src/server.ts",
|
"src/server.ts",
|
||||||
"src/server/lib/audit/progress-kv.ts",
|
"src/server/lib/audit/progress-kv.ts",
|
||||||
"src/server/lib/audit/types.ts",
|
"src/server/lib/audit/types.ts",
|
||||||
"src/server/services/PsiIssuesService.ts",
|
|
||||||
"src/server/workflows/SiteAuditWorkflow.ts",
|
"src/server/workflows/SiteAuditWorkflow.ts",
|
||||||
"src/serverFunctions/keywords.ts",
|
"src/serverFunctions/keywords.ts",
|
||||||
"src/serverFunctions/psi.ts",
|
"src/serverFunctions/psi.ts",
|
||||||
"src/types/schemas/audit.ts",
|
"src/types/schemas/audit.ts",
|
||||||
"src/types/schemas/psi.ts",
|
"src/types/schemas/psi.ts",
|
||||||
],
|
],
|
||||||
"ignoreFiles": [
|
|
||||||
"src/server/services/keyword-research/helpers.ts",
|
|
||||||
"src/server/services/keyword-research/projects.ts",
|
|
||||||
"src/server/services/keyword-research/research-data.ts",
|
|
||||||
"src/server/services/keyword-research/saved-keywords.ts",
|
|
||||||
"src/server/services/keyword-research/serp.ts",
|
|
||||||
],
|
|
||||||
// Disable Drizzle plugin - it tries to load drizzle.config.ts which imports cloudflare:workers
|
// Disable Drizzle plugin - it tries to load drizzle.config.ts which imports cloudflare:workers
|
||||||
"drizzle": false,
|
"drizzle": false,
|
||||||
"ignoreDependencies": [
|
"ignoreDependencies": [
|
||||||
@ -40,6 +30,5 @@
|
|||||||
"daisyui",
|
"daisyui",
|
||||||
"@tanstack/query-sync-storage-persister",
|
"@tanstack/query-sync-storage-persister",
|
||||||
"@tanstack/react-query-persist-client",
|
"@tanstack/react-query-persist-client",
|
||||||
"portless",
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"include": ["**/*.ts", "**/*.tsx"],
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": ["web/**/*"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
|
|||||||
9
web/.gitignore
vendored
Normal file
9
web/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.wrangler
|
||||||
|
.vinxi
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.source
|
||||||
|
source.generated.ts
|
||||||
|
*.local
|
||||||
9
web/.prettierignore
Normal file
9
web/.prettierignore
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.wrangler
|
||||||
|
.vinxi
|
||||||
|
.output
|
||||||
|
.source
|
||||||
|
source.generated.ts
|
||||||
|
pnpm-lock.yaml
|
||||||
|
routeTree.gen.ts
|
||||||
1
web/.worktreeinclude
Normal file
1
web/.worktreeinclude
Normal file
@ -0,0 +1 @@
|
|||||||
|
web/open-rank
|
||||||
47
web/package.json
Normal file
47
web/package.json
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "open-rank-landing",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build && node scripts/generate-sitemap.js",
|
||||||
|
"build:app": "vite build",
|
||||||
|
"sitemap": "node scripts/generate-sitemap.js",
|
||||||
|
"start": "wrangler dev",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"types:check": "fumadocs-mdx && tsc --noEmit",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"format:write": "prettier --write .",
|
||||||
|
"deploy": "npm run build && wrangler deploy",
|
||||||
|
"deploy:prod": "npm run build && wrangler deploy",
|
||||||
|
"deploy:preview": "npm run build && wrangler deploy --env preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-router": "^1.161.3",
|
||||||
|
"@tanstack/react-router-devtools": "^1.161.3",
|
||||||
|
"@tanstack/react-start": "^1.161.3",
|
||||||
|
"fumadocs-core": "^15.5.1",
|
||||||
|
"fumadocs-mdx": "^11.6.5",
|
||||||
|
"fumadocs-ui": "^15.5.1",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"zod": "^3.24.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/vite-plugin": "^1.13.3",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/mdx": "^2.0.13",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"@types/react": "^19.2.13",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.3",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"srvx": "^0.11.2",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite-tsconfig-paths": "^6.0.5",
|
||||||
|
"wrangler": "^4.67.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
5868
web/pnpm-lock.yaml
generated
Normal file
5868
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
web/public/demo-poster.webp
Normal file
BIN
web/public/demo-poster.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
web/public/demo.gif
Normal file
BIN
web/public/demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 MiB |
BIN
web/public/demo.mp4
Normal file
BIN
web/public/demo.mp4
Normal file
Binary file not shown.
BIN
web/public/demo.webm
Normal file
BIN
web/public/demo.webm
Normal file
Binary file not shown.
4
web/public/robots.txt
Normal file
4
web/public/robots.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
|
Sitemap: https://openrank.io/sitemap.xml
|
||||||
52
web/scripts/generate-sitemap.js
Normal file
52
web/scripts/generate-sitemap.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { existsSync, statSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const DIST_DIR = join(__dirname, "../dist/client");
|
||||||
|
|
||||||
|
const DEFAULT_SITE_URL = "https://openrank.io";
|
||||||
|
const SITE_URL = (process.env.SITE_URL ?? DEFAULT_SITE_URL).replace(/\/+$/, "");
|
||||||
|
|
||||||
|
const STATIC_PATHS = new Set(["/", "/privacy"]);
|
||||||
|
|
||||||
|
function toCanonicalUrl(path) {
|
||||||
|
if (path === "/") {
|
||||||
|
return `${SITE_URL}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${SITE_URL}${path.replace(/\/+$/, "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
if (!existsSync(DIST_DIR) || !statSync(DIST_DIR).isDirectory()) {
|
||||||
|
throw new Error(`Build output directory does not exist: ${DIST_DIR}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlPaths = new Set(STATIC_PATHS);
|
||||||
|
|
||||||
|
const urls = Array.from(urlPaths)
|
||||||
|
.map((path) => toCanonicalUrl(path))
|
||||||
|
.sort((a, b) => a.localeCompare(b));
|
||||||
|
|
||||||
|
const lastmod = new Date().toISOString();
|
||||||
|
const sitemapBody = urls
|
||||||
|
.map(
|
||||||
|
(url) =>
|
||||||
|
` <url>\n <loc>${url}</loc>\n <lastmod>${lastmod}</lastmod>\n </url>`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const sitemapXml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${sitemapBody}\n</urlset>\n`;
|
||||||
|
|
||||||
|
const sitemapPath = join(DIST_DIR, "sitemap.xml");
|
||||||
|
writeFileSync(sitemapPath, sitemapXml);
|
||||||
|
|
||||||
|
console.log(`Generated sitemap with ${urls.length} URLs at ${sitemapPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
17
web/source.config.ts
Normal file
17
web/source.config.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import {
|
||||||
|
defineConfig,
|
||||||
|
defineCollections,
|
||||||
|
frontmatterSchema,
|
||||||
|
} from "fumadocs-mdx/config/zod-3";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const blog = defineCollections({
|
||||||
|
type: "doc",
|
||||||
|
dir: "content/blog",
|
||||||
|
schema: (frontmatterSchema as any).extend({
|
||||||
|
author: z.string(),
|
||||||
|
date: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineConfig();
|
||||||
24
web/src/components/not-found.tsx
Normal file
24
web/src/components/not-found.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
import { HomeLayout } from "fumadocs-ui/layouts/home";
|
||||||
|
import { baseOptions } from "@/lib/layout.shared";
|
||||||
|
|
||||||
|
export function NotFound() {
|
||||||
|
return (
|
||||||
|
<HomeLayout {...baseOptions()} className="text-center py-32 justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<h1 className="text-6xl font-bold text-fd-muted-foreground">404</h1>
|
||||||
|
<h2 className="text-2xl font-semibold">Page Not Found</h2>
|
||||||
|
<p className="text-fd-muted-foreground max-w-md">
|
||||||
|
The page you are looking for might have been removed, had its name
|
||||||
|
changed, or is temporarily unavailable.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="mt-4 px-4 py-2 rounded-lg bg-fd-primary text-fd-primary-foreground font-medium text-sm hover:opacity-90 transition-opacity"
|
||||||
|
>
|
||||||
|
Back to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</HomeLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
web/src/lib/content.functions.ts
Normal file
29
web/src/lib/content.functions.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { notFound } from "@tanstack/react-router";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { blogSource } from "@/lib/source";
|
||||||
|
|
||||||
|
export const getBlogPost = createServerFn({ method: "GET" })
|
||||||
|
.inputValidator((slugs: string[]) => slugs)
|
||||||
|
.handler(async ({ data: slugs }) => {
|
||||||
|
const page = blogSource.getPage(slugs);
|
||||||
|
if (!page) throw notFound();
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: page.path,
|
||||||
|
title: page.data.title,
|
||||||
|
description: page.data.description,
|
||||||
|
url: page.url,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getBlogPosts = createServerFn({ method: "GET" }).handler(
|
||||||
|
async () => {
|
||||||
|
const pages = blogSource.getPages();
|
||||||
|
return pages.map((page) => ({
|
||||||
|
title: page.data.title,
|
||||||
|
description: page.data.description,
|
||||||
|
url: page.url,
|
||||||
|
slugs: page.slugs,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
);
|
||||||
16
web/src/lib/layout.shared.tsx
Normal file
16
web/src/lib/layout.shared.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||||
|
|
||||||
|
export function baseOptions(): BaseLayoutProps {
|
||||||
|
return {
|
||||||
|
nav: {
|
||||||
|
title: <span className="font-semibold">OpenRank</span>,
|
||||||
|
},
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
text: "GitHub",
|
||||||
|
url: "https://github.com/every-app/open-seo",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
51
web/src/lib/seo.ts
Normal file
51
web/src/lib/seo.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
const DEFAULT_SITE_URL = "https://openrank.io";
|
||||||
|
|
||||||
|
export const SITE_URL = (
|
||||||
|
process.env.SITE_URL ??
|
||||||
|
process.env.VITE_SITE_URL ??
|
||||||
|
DEFAULT_SITE_URL
|
||||||
|
).replace(/\/+$/, "");
|
||||||
|
|
||||||
|
export function toCanonicalPath(path: string): string {
|
||||||
|
if (!path || path === "/") return "/";
|
||||||
|
|
||||||
|
const normalized = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
return normalized.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCanonicalUrl(path: string): string {
|
||||||
|
return new URL(toCanonicalPath(path), `${SITE_URL}/`).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BuildSeoParams = {
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
description?: string;
|
||||||
|
titleSuffix?: string;
|
||||||
|
ogType?: "website" | "article";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildPageSeo({
|
||||||
|
title,
|
||||||
|
path,
|
||||||
|
description,
|
||||||
|
titleSuffix,
|
||||||
|
ogType = "website",
|
||||||
|
}: BuildSeoParams) {
|
||||||
|
const fullTitle = titleSuffix ? `${title} - ${titleSuffix}` : title;
|
||||||
|
const canonicalUrl = toCanonicalUrl(path);
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: [
|
||||||
|
{ title: fullTitle },
|
||||||
|
...(description ? [{ name: "description", content: description }] : []),
|
||||||
|
{ property: "og:type", content: ogType },
|
||||||
|
{ property: "og:title", content: fullTitle },
|
||||||
|
...(description
|
||||||
|
? [{ property: "og:description", content: description }]
|
||||||
|
: []),
|
||||||
|
{ property: "og:url", content: canonicalUrl },
|
||||||
|
],
|
||||||
|
links: [{ rel: "canonical", href: canonicalUrl }],
|
||||||
|
};
|
||||||
|
}
|
||||||
15
web/src/lib/source.ts
Normal file
15
web/src/lib/source.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { loader } from "fumadocs-core/source";
|
||||||
|
// Keep this deep import for now: the public "fumadocs-mdx/runtime/vite" entry
|
||||||
|
// resolves to the browser runtime in our SSR build, which breaks sourceAsync.
|
||||||
|
import { fromConfig } from "../../node_modules/fumadocs-mdx/dist/runtime/vite/server.js";
|
||||||
|
import { blog } from "../../source.generated";
|
||||||
|
import type * as Config from "../../source.config";
|
||||||
|
|
||||||
|
const serverCreate = fromConfig<typeof Config>();
|
||||||
|
|
||||||
|
export const blogSource = loader({
|
||||||
|
source: await serverCreate.sourceAsync(blog, {} as Record<string, never>),
|
||||||
|
baseUrl: "/blogs",
|
||||||
|
});
|
||||||
|
|
||||||
|
export { blog };
|
||||||
198
web/src/routeTree.gen.ts
Normal file
198
web/src/routeTree.gen.ts
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
|
||||||
|
// This file was automatically generated by TanStack Router.
|
||||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as PrivacyRouteImport } from './routes/privacy'
|
||||||
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as BlogsIndexRouteImport } from './routes/blogs/index'
|
||||||
|
import { Route as JsScriptDotjsRouteImport } from './routes/js/script[.]js'
|
||||||
|
import { Route as BlogsSplatRouteImport } from './routes/blogs/$'
|
||||||
|
import { Route as ApiSubscribeRouteImport } from './routes/api/subscribe'
|
||||||
|
import { Route as ApiEventRouteImport } from './routes/api/event'
|
||||||
|
|
||||||
|
const PrivacyRoute = PrivacyRouteImport.update({
|
||||||
|
id: '/privacy',
|
||||||
|
path: '/privacy',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const BlogsIndexRoute = BlogsIndexRouteImport.update({
|
||||||
|
id: '/blogs/',
|
||||||
|
path: '/blogs/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const JsScriptDotjsRoute = JsScriptDotjsRouteImport.update({
|
||||||
|
id: '/js/script.js',
|
||||||
|
path: '/js/script.js',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const BlogsSplatRoute = BlogsSplatRouteImport.update({
|
||||||
|
id: '/blogs/$',
|
||||||
|
path: '/blogs/$',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSubscribeRoute = ApiSubscribeRouteImport.update({
|
||||||
|
id: '/api/subscribe',
|
||||||
|
path: '/api/subscribe',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiEventRoute = ApiEventRouteImport.update({
|
||||||
|
id: '/api/event',
|
||||||
|
path: '/api/event',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/privacy': typeof PrivacyRoute
|
||||||
|
'/api/event': typeof ApiEventRoute
|
||||||
|
'/api/subscribe': typeof ApiSubscribeRoute
|
||||||
|
'/blogs/$': typeof BlogsSplatRoute
|
||||||
|
'/js/script.js': typeof JsScriptDotjsRoute
|
||||||
|
'/blogs/': typeof BlogsIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/privacy': typeof PrivacyRoute
|
||||||
|
'/api/event': typeof ApiEventRoute
|
||||||
|
'/api/subscribe': typeof ApiSubscribeRoute
|
||||||
|
'/blogs/$': typeof BlogsSplatRoute
|
||||||
|
'/js/script.js': typeof JsScriptDotjsRoute
|
||||||
|
'/blogs': typeof BlogsIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/privacy': typeof PrivacyRoute
|
||||||
|
'/api/event': typeof ApiEventRoute
|
||||||
|
'/api/subscribe': typeof ApiSubscribeRoute
|
||||||
|
'/blogs/$': typeof BlogsSplatRoute
|
||||||
|
'/js/script.js': typeof JsScriptDotjsRoute
|
||||||
|
'/blogs/': typeof BlogsIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/privacy'
|
||||||
|
| '/api/event'
|
||||||
|
| '/api/subscribe'
|
||||||
|
| '/blogs/$'
|
||||||
|
| '/js/script.js'
|
||||||
|
| '/blogs/'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to:
|
||||||
|
| '/'
|
||||||
|
| '/privacy'
|
||||||
|
| '/api/event'
|
||||||
|
| '/api/subscribe'
|
||||||
|
| '/blogs/$'
|
||||||
|
| '/js/script.js'
|
||||||
|
| '/blogs'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/privacy'
|
||||||
|
| '/api/event'
|
||||||
|
| '/api/subscribe'
|
||||||
|
| '/blogs/$'
|
||||||
|
| '/js/script.js'
|
||||||
|
| '/blogs/'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
PrivacyRoute: typeof PrivacyRoute
|
||||||
|
ApiEventRoute: typeof ApiEventRoute
|
||||||
|
ApiSubscribeRoute: typeof ApiSubscribeRoute
|
||||||
|
BlogsSplatRoute: typeof BlogsSplatRoute
|
||||||
|
JsScriptDotjsRoute: typeof JsScriptDotjsRoute
|
||||||
|
BlogsIndexRoute: typeof BlogsIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/privacy': {
|
||||||
|
id: '/privacy'
|
||||||
|
path: '/privacy'
|
||||||
|
fullPath: '/privacy'
|
||||||
|
preLoaderRoute: typeof PrivacyRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/': {
|
||||||
|
id: '/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/blogs/': {
|
||||||
|
id: '/blogs/'
|
||||||
|
path: '/blogs'
|
||||||
|
fullPath: '/blogs/'
|
||||||
|
preLoaderRoute: typeof BlogsIndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/js/script.js': {
|
||||||
|
id: '/js/script.js'
|
||||||
|
path: '/js/script.js'
|
||||||
|
fullPath: '/js/script.js'
|
||||||
|
preLoaderRoute: typeof JsScriptDotjsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/blogs/$': {
|
||||||
|
id: '/blogs/$'
|
||||||
|
path: '/blogs/$'
|
||||||
|
fullPath: '/blogs/$'
|
||||||
|
preLoaderRoute: typeof BlogsSplatRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/subscribe': {
|
||||||
|
id: '/api/subscribe'
|
||||||
|
path: '/api/subscribe'
|
||||||
|
fullPath: '/api/subscribe'
|
||||||
|
preLoaderRoute: typeof ApiSubscribeRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/event': {
|
||||||
|
id: '/api/event'
|
||||||
|
path: '/api/event'
|
||||||
|
fullPath: '/api/event'
|
||||||
|
preLoaderRoute: typeof ApiEventRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
PrivacyRoute: PrivacyRoute,
|
||||||
|
ApiEventRoute: ApiEventRoute,
|
||||||
|
ApiSubscribeRoute: ApiSubscribeRoute,
|
||||||
|
BlogsSplatRoute: BlogsSplatRoute,
|
||||||
|
JsScriptDotjsRoute: JsScriptDotjsRoute,
|
||||||
|
BlogsIndexRoute: BlogsIndexRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
|
|
||||||
|
import type { getRouter } from './router.tsx'
|
||||||
|
import type { createStart } from '@tanstack/react-start'
|
||||||
|
declare module '@tanstack/react-start' {
|
||||||
|
interface Register {
|
||||||
|
ssr: true
|
||||||
|
router: Awaited<ReturnType<typeof getRouter>>
|
||||||
|
}
|
||||||
|
}
|
||||||
12
web/src/router.tsx
Normal file
12
web/src/router.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||||
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
import { NotFound } from "@/components/not-found";
|
||||||
|
|
||||||
|
export function getRouter() {
|
||||||
|
return createTanStackRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: "intent",
|
||||||
|
scrollRestoration: true,
|
||||||
|
defaultNotFoundComponent: NotFound,
|
||||||
|
});
|
||||||
|
}
|
||||||
56
web/src/routes/__root.tsx
Normal file
56
web/src/routes/__root.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
createRootRoute,
|
||||||
|
HeadContent,
|
||||||
|
Outlet,
|
||||||
|
Scripts,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
|
import * as React from "react";
|
||||||
|
import appCss from "@/styles/app.css?url";
|
||||||
|
import { RootProvider } from "fumadocs-ui/provider/tanstack";
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
charSet: "utf-8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "viewport",
|
||||||
|
content: "width=device-width, initial-scale=1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{ rel: "stylesheet", href: appCss },
|
||||||
|
{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
component: RootComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RootComponent() {
|
||||||
|
return (
|
||||||
|
<RootDocument>
|
||||||
|
<Outlet />
|
||||||
|
</RootDocument>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<HeadContent />
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html:
|
||||||
|
"(function(){function loadAnalytics(){if(window.__openrankAnalyticsLoaded)return;window.__openrankAnalyticsLoaded=true;window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)};plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init({endpoint:'/api/event'});var script=document.createElement('script');script.defer=true;script.src='/js/script.js';document.head.appendChild(script)}function schedule(){if('requestIdleCallback'in window){window.requestIdleCallback(loadAnalytics,{timeout:2000});return}window.setTimeout(loadAnalytics,2000)}if(document.readyState==='complete'){schedule();return}window.addEventListener('load',schedule,{once:true})})();",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="flex flex-col min-h-screen bg-white">
|
||||||
|
<RootProvider>{children}</RootProvider>
|
||||||
|
<Scripts />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
web/src/routes/api/event.ts
Normal file
20
web/src/routes/api/event.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
const PLAUSIBLE_EVENT_URL = "https://plausible.io/api/event";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/api/event")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
POST: async ({ request }) => {
|
||||||
|
const proxyRequest = new Request(PLAUSIBLE_EVENT_URL, request);
|
||||||
|
proxyRequest.headers.delete("cookie");
|
||||||
|
|
||||||
|
const upstreamResponse = await fetch(proxyRequest);
|
||||||
|
return new Response(upstreamResponse.body, {
|
||||||
|
status: upstreamResponse.status,
|
||||||
|
headers: upstreamResponse.headers,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
86
web/src/routes/api/subscribe.ts
Normal file
86
web/src/routes/api/subscribe.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { env } from "cloudflare:workers";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const subscribeSchema = z.object({
|
||||||
|
email: z.string().email("Please enter a valid email address"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/api/subscribe")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
POST: async ({ request }) => {
|
||||||
|
const body = await request.json();
|
||||||
|
const parsed = subscribeSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: parsed.error.issues[0]?.message }),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loopsApiKey = (env as any).LOOPS_API_KEY as string | undefined;
|
||||||
|
|
||||||
|
if (!loopsApiKey) {
|
||||||
|
console.error("Missing LOOPS_API_KEY");
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Service temporarily unavailable" }),
|
||||||
|
{ status: 503, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loopsResponse = await fetch(
|
||||||
|
"https://app.loops.so/api/v1/contacts/create",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${loopsApiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: parsed.data.email,
|
||||||
|
source: "openrank-waitlist",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loopsResponse.status === 409) {
|
||||||
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loopsResponse.ok) {
|
||||||
|
const loopsError = await loopsResponse.json().catch(() => null);
|
||||||
|
console.error("Loops contact creation error:", loopsError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Failed to subscribe. Please try again.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Subscribe endpoint error:", err);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Failed to subscribe. Please try again.",
|
||||||
|
}),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
76
web/src/routes/blogs/$.tsx
Normal file
76
web/src/routes/blogs/$.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { HomeLayout } from "fumadocs-ui/layouts/home";
|
||||||
|
import { createClientLoader } from "fumadocs-mdx/runtime/vite";
|
||||||
|
import { DocsBody } from "fumadocs-ui/page";
|
||||||
|
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||||
|
import { baseOptions } from "@/lib/layout.shared";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { getBlogPost } from "@/lib/content.functions";
|
||||||
|
import { blog } from "../../../source.generated";
|
||||||
|
import { buildPageSeo } from "@/lib/seo";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/blogs/$")({
|
||||||
|
loader: async ({ params }: { params: { _splat?: string } }) => {
|
||||||
|
const slugs = params._splat?.split("/") ?? [];
|
||||||
|
const data = await getBlogPost({ data: slugs });
|
||||||
|
await clientMdxLoader.preload(data.path);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
head: ({ loaderData }: { loaderData?: unknown }) => {
|
||||||
|
const data = loaderData as
|
||||||
|
| { title?: string; description?: string; url?: string }
|
||||||
|
| undefined;
|
||||||
|
const title = data?.title ?? "OpenRank Blog";
|
||||||
|
const description = data?.description;
|
||||||
|
return buildPageSeo({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
path: data?.url ?? "/blogs",
|
||||||
|
titleSuffix: "OpenRank Blog",
|
||||||
|
ogType: "article",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
component: BlogPost,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientMdxLoader = createClientLoader(blog, {
|
||||||
|
id: "blog",
|
||||||
|
component({ default: MDX }) {
|
||||||
|
return (
|
||||||
|
<DocsBody>
|
||||||
|
<MDX
|
||||||
|
components={{
|
||||||
|
...defaultMdxComponents,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DocsBody>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function BlogPost() {
|
||||||
|
const data = Route.useLoaderData() as {
|
||||||
|
path: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
const Content = clientMdxLoader.getComponent(data.path);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HomeLayout {...baseOptions()}>
|
||||||
|
<article className="max-w-3xl mx-auto px-6 py-12 md:py-24">
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold mb-4">{data.title}</h1>
|
||||||
|
{data.description && (
|
||||||
|
<p className="text-lg text-fd-muted-foreground">
|
||||||
|
{data.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<Suspense>
|
||||||
|
<Content />
|
||||||
|
</Suspense>
|
||||||
|
</article>
|
||||||
|
</HomeLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
web/src/routes/blogs/index.tsx
Normal file
58
web/src/routes/blogs/index.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { HomeLayout } from "fumadocs-ui/layouts/home";
|
||||||
|
import { baseOptions } from "@/lib/layout.shared";
|
||||||
|
import { getBlogPosts } from "@/lib/content.functions";
|
||||||
|
import { buildPageSeo } from "@/lib/seo";
|
||||||
|
|
||||||
|
const blogIndexDescription =
|
||||||
|
"Updates and guides from OpenRank.";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/blogs/")({
|
||||||
|
head: () =>
|
||||||
|
buildPageSeo({
|
||||||
|
title: "OpenRank Blog",
|
||||||
|
description: blogIndexDescription,
|
||||||
|
path: "/blogs",
|
||||||
|
}),
|
||||||
|
component: BlogIndex,
|
||||||
|
loader: async () => await getBlogPosts(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function BlogIndex() {
|
||||||
|
const posts = Route.useLoaderData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HomeLayout {...baseOptions()}>
|
||||||
|
<div className="max-w-3xl mx-auto px-6 py-12 md:py-24">
|
||||||
|
<h1 className="text-4xl font-bold mb-8">Blog</h1>
|
||||||
|
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<p className="text-fd-muted-foreground">
|
||||||
|
No blog posts yet. Check back soon.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<article key={post.url} className="border-b pb-8 last:border-b-0">
|
||||||
|
<Link
|
||||||
|
to="/blogs/$"
|
||||||
|
params={{ _splat: post.slugs.join("/") }}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
<h2 className="text-2xl font-semibold group-hover:text-fd-primary transition-colors mb-2">
|
||||||
|
{post.title}
|
||||||
|
</h2>
|
||||||
|
{post.description && (
|
||||||
|
<p className="text-fd-muted-foreground">
|
||||||
|
{post.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</HomeLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
web/src/routes/index.tsx
Normal file
248
web/src/routes/index.tsx
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toCanonicalUrl } from "@/lib/seo";
|
||||||
|
|
||||||
|
const homeTitle = "OpenRank - Own Your SEO";
|
||||||
|
const homeDescription =
|
||||||
|
"Own your SEO. Pay only for what you use. No subscriptions. Open source alternative to Semrush and Ahrefs.";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/")({
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{ title: homeTitle },
|
||||||
|
{ name: "description", content: homeDescription },
|
||||||
|
{ property: "og:type", content: "website" },
|
||||||
|
{ property: "og:title", content: homeTitle },
|
||||||
|
{ property: "og:description", content: homeDescription },
|
||||||
|
{ property: "og:url", content: toCanonicalUrl("/") },
|
||||||
|
{ name: "twitter:card", content: "summary_large_image" },
|
||||||
|
{ name: "twitter:title", content: homeTitle },
|
||||||
|
{ name: "twitter:description", content: homeDescription },
|
||||||
|
],
|
||||||
|
links: [{ rel: "canonical", href: toCanonicalUrl("/") }],
|
||||||
|
}),
|
||||||
|
component: Home,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Shared ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function GitHubIcon({ size = 18 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useWaitlist() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [status, setStatus] = useState<
|
||||||
|
"idle" | "loading" | "success" | "error"
|
||||||
|
>("idle");
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setStatus("loading");
|
||||||
|
setErrorMessage("");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/subscribe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
(data as { error?: string }).error || "Something went wrong",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setStatus("success");
|
||||||
|
setEmail("");
|
||||||
|
} catch (err) {
|
||||||
|
setStatus("error");
|
||||||
|
setErrorMessage(
|
||||||
|
err instanceof Error ? err.message : "Something went wrong",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { email, setEmail, status, errorMessage, handleSubmit };
|
||||||
|
}
|
||||||
|
|
||||||
|
function FooterLinks({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-6 ${className || ""}`}>
|
||||||
|
<a
|
||||||
|
href="https://github.com/every-app/open-seo"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://discord.gg/c9uGs3cFXr"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Discord
|
||||||
|
</a>
|
||||||
|
<Link to="/privacy">Privacy</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Page ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
const wl = useWaitlist();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
className="bg-white text-neutral-900 min-h-screen"
|
||||||
|
style={{
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="max-w-3xl mx-auto px-6 py-16 md:py-24">
|
||||||
|
{/* Nav */}
|
||||||
|
<nav className="flex items-center justify-between mb-20">
|
||||||
|
<span className="text-sm font-semibold">OpenRank</span>
|
||||||
|
<a
|
||||||
|
href="https://github.com/every-app/open-seo"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 text-sm font-medium text-neutral-700 hover:text-neutral-900 transition-colors"
|
||||||
|
aria-label="GitHub"
|
||||||
|
>
|
||||||
|
<GitHubIcon size={16} />
|
||||||
|
<span>GitHub</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Headline */}
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight leading-tight">
|
||||||
|
Own your SEO
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-neutral-700 mt-4 leading-relaxed">
|
||||||
|
Open source alternative to Semrush and Ahrefs
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<ul className="mt-5 space-y-3">
|
||||||
|
{[
|
||||||
|
"Keyword Research",
|
||||||
|
"Competitor Domain Insights",
|
||||||
|
"Site Audits",
|
||||||
|
"Backlinks, Rank Tracking and more (Coming soon)",
|
||||||
|
].map((item) => (
|
||||||
|
<li key={item} className="flex gap-2.5 text-sm text-neutral-800">
|
||||||
|
<span className="text-neutral-500 mt-[2px]">—</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="mt-6">
|
||||||
|
{wl.status === "success" ? (
|
||||||
|
<p className="text-sm text-neutral-900">You're on the list.</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={wl.handleSubmit} autoComplete="on">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
id="waitlist-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={wl.email}
|
||||||
|
onChange={(e) => wl.setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="flex-1 min-w-0 h-10 px-3 text-sm border border-neutral-300 rounded-md bg-white placeholder:text-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-900 transition-all"
|
||||||
|
disabled={wl.status === "loading"}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={wl.status === "loading"}
|
||||||
|
className="h-10 px-5 text-sm font-medium bg-neutral-900 text-white rounded-md hover:bg-neutral-800 transition-colors disabled:opacity-50 shrink-0"
|
||||||
|
>
|
||||||
|
{wl.status === "loading" ? "..." : "Notify me"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{wl.status === "error" && (
|
||||||
|
<p className="text-red-600 text-xs mt-2">{wl.errorMessage}</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-neutral-600 mt-3">
|
||||||
|
Get notified when the managed version is ready and when we add new
|
||||||
|
features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 rounded-lg border border-neutral-200 bg-neutral-50 px-5 py-5">
|
||||||
|
<p className="text-sm font-semibold text-neutral-900">
|
||||||
|
Self-host today via Docker or Cloudflare
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-600 mt-2 leading-relaxed">
|
||||||
|
Bring your own DataForSEO API key. Pay by usage, not per month. 100%
|
||||||
|
open source (MIT).
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://github.com/every-app/open-seo"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 mt-3 text-sm font-medium text-neutral-900 hover:text-neutral-700 transition-colors"
|
||||||
|
>
|
||||||
|
View on GitHub
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</a>
|
||||||
|
<hr className="mt-4" />
|
||||||
|
<p className="text-sm text-neutral-600 mt-2 leading-relaxed">
|
||||||
|
If you don't want to self host or the DataForSEO minimum commitments
|
||||||
|
are too high, sign up in the form above and we'll notify you when
|
||||||
|
the managed version is released.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Demo */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<video
|
||||||
|
className="w-full rounded-md border border-neutral-200"
|
||||||
|
width={1280}
|
||||||
|
height={808}
|
||||||
|
poster="/demo-poster.webp"
|
||||||
|
autoPlay
|
||||||
|
muted
|
||||||
|
loop
|
||||||
|
playsInline
|
||||||
|
preload="none"
|
||||||
|
aria-label="OpenRank product demo"
|
||||||
|
>
|
||||||
|
<source src="/demo.webm" type="video/webm" />
|
||||||
|
<source src="/demo.mp4" type="video/mp4" />
|
||||||
|
<img
|
||||||
|
src="/demo-poster.webp"
|
||||||
|
alt="OpenRank product demo"
|
||||||
|
width={1280}
|
||||||
|
height={808}
|
||||||
|
className="w-full rounded-md border border-neutral-200"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</video>
|
||||||
|
<p className="text-[11px] text-neutral-600 mt-2">
|
||||||
|
Keyword research in OpenRank
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-8 pt-8 border-t border-neutral-200">
|
||||||
|
<FooterLinks className="text-xs text-neutral-600 [&_a]:hover:text-neutral-900 [&_a]:transition-colors" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
web/src/routes/js/script[.]js.ts
Normal file
30
web/src/routes/js/script[.]js.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
const PLAUSIBLE_SCRIPT_URL = "https://plausible.io/js/pa-DllmchvGzcNdY2jEy1-Hc.js";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/js/script.js")({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
GET: async () => {
|
||||||
|
const upstreamResponse = await fetch(PLAUSIBLE_SCRIPT_URL);
|
||||||
|
|
||||||
|
if (!upstreamResponse.ok) {
|
||||||
|
return new Response("Failed to load analytics script", {
|
||||||
|
status: 502,
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/plain; charset=utf-8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = new Headers(upstreamResponse.headers);
|
||||||
|
headers.set("cache-control", "public, max-age=86400, immutable");
|
||||||
|
|
||||||
|
return new Response(upstreamResponse.body, {
|
||||||
|
status: upstreamResponse.status,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
72
web/src/routes/privacy.tsx
Normal file
72
web/src/routes/privacy.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { buildPageSeo } from "@/lib/seo";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/privacy")({
|
||||||
|
head: () =>
|
||||||
|
buildPageSeo({
|
||||||
|
title: "Privacy Policy",
|
||||||
|
description: "OpenRank privacy policy",
|
||||||
|
path: "/privacy",
|
||||||
|
titleSuffix: "OpenRank",
|
||||||
|
}),
|
||||||
|
component: Privacy,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Privacy() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<header className="px-6 py-4 border-b border-zinc-200">
|
||||||
|
<Link to="/" className="font-semibold text-zinc-900 hover:opacity-80">
|
||||||
|
OpenRank
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article className="max-w-3xl mx-auto px-6 py-12 md:py-24 prose prose-gray text-zinc-900 prose-headings:text-zinc-900 prose-p:text-zinc-700 prose-a:text-zinc-900">
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<p className="text-fd-muted-foreground">Last updated: March 2026</p>
|
||||||
|
|
||||||
|
<h2>What we collect</h2>
|
||||||
|
<p>
|
||||||
|
When you sign up for our waitlist, we collect your email address. This
|
||||||
|
is stored securely via Loops.so, our email service provider.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>How we use it</h2>
|
||||||
|
<p>
|
||||||
|
We use your email address to send launch announcements, product updates,
|
||||||
|
and related OpenRank marketing emails. You can unsubscribe at any time
|
||||||
|
using the unsubscribe link in any email. We do not sell your data or
|
||||||
|
share it with third parties beyond our service providers.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Website analytics</h2>
|
||||||
|
<p>
|
||||||
|
We use Plausible Analytics to understand aggregate website traffic and
|
||||||
|
usage patterns (for example, page views and referring sites). Plausible
|
||||||
|
is cookie-free and does not use cross-site tracking. We route analytics
|
||||||
|
requests through our own domain before forwarding them to Plausible.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Data storage</h2>
|
||||||
|
<p>
|
||||||
|
Your email address is stored in Loops.so's infrastructure. You can request
|
||||||
|
deletion of your data at any time by emailing{" "}
|
||||||
|
<a href="mailto:privacy@everyapp.dev">privacy@everyapp.dev</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Self-hosted usage</h2>
|
||||||
|
<p>
|
||||||
|
If you self-host OpenRank, no data is sent to us. The self-hosted
|
||||||
|
version communicates directly with DataForSEO's APIs using your own
|
||||||
|
credentials.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>
|
||||||
|
Questions about this policy? Email{" "}
|
||||||
|
<a href="mailto:privacy@everyapp.dev">privacy@everyapp.dev</a>.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
web/src/styles/app.css
Normal file
12
web/src/styles/app.css
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "fumadocs-ui/css/neutral.css";
|
||||||
|
@import "fumadocs-ui/css/preset.css";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-brand: #1a1a1a;
|
||||||
|
--color-brand-muted: #6b7280;
|
||||||
|
--color-brand-accent: #2563eb;
|
||||||
|
--color-surface: #fafafa;
|
||||||
|
--color-surface-raised: #ffffff;
|
||||||
|
--color-border-subtle: #e5e7eb;
|
||||||
|
}
|
||||||
4
web/src/types/cloudflare-workers.d.ts
vendored
Normal file
4
web/src/types/cloudflare-workers.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
declare module "cloudflare:workers" {
|
||||||
|
const env: Env;
|
||||||
|
export { env };
|
||||||
|
}
|
||||||
24
web/tsconfig.json
Normal file
24
web/tsconfig.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"isolatedModules": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"allowJs": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
"fumadocs-mdx:collections/*": [".source/*"]
|
||||||
|
},
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
34
web/vite.config.ts
Normal file
34
web/vite.config.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||||
|
import { cloudflare } from "@cloudflare/vite-plugin";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import tsConfigPaths from "vite-tsconfig-paths";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import mdx from "fumadocs-mdx/vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
port: 4322,
|
||||||
|
},
|
||||||
|
ssr: {
|
||||||
|
resolve: {
|
||||||
|
conditions: ["worker", "import", "module", "default"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
mdx(await import("./source.config")),
|
||||||
|
tailwindcss(),
|
||||||
|
tsConfigPaths({
|
||||||
|
projects: ["./tsconfig.json"],
|
||||||
|
}),
|
||||||
|
cloudflare({
|
||||||
|
viteEnvironment: { name: "ssr" },
|
||||||
|
}),
|
||||||
|
tanstackStart({
|
||||||
|
prerender: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
react(),
|
||||||
|
],
|
||||||
|
});
|
||||||
5
web/worker-configuration.d.ts
vendored
Normal file
5
web/worker-configuration.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
interface Env {
|
||||||
|
RESEND_API_KEY: string;
|
||||||
|
RESEND_AUDIENCE_ID: string;
|
||||||
|
RESEND_SEGMENT_ID: string;
|
||||||
|
}
|
||||||
28
web/wrangler.jsonc
Normal file
28
web/wrangler.jsonc
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"name": "open-rank-landing",
|
||||||
|
"compatibility_date": "2026-02-19",
|
||||||
|
"compatibility_flags": [
|
||||||
|
"nodejs_compat"
|
||||||
|
],
|
||||||
|
"main": "@tanstack/react-start/server-entry",
|
||||||
|
"assets": {
|
||||||
|
"directory": "./dist/client",
|
||||||
|
"html_handling": "drop-trailing-slash",
|
||||||
|
},
|
||||||
|
"workers_dev": true,
|
||||||
|
"preview_urls": true,
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"pattern": "openrank.io",
|
||||||
|
"custom_domain": true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"preview": {
|
||||||
|
"name": "open-rank-landing-preview",
|
||||||
|
"workers_dev": true,
|
||||||
|
"preview_urls": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user