@geenius/seo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/config.json +11 -0
- package/.github/CODEOWNERS +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +16 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +11 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +10 -0
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/ci.yml +23 -0
- package/.github/workflows/release.yml +29 -0
- package/.nvmrc +1 -0
- package/.project/ACCOUNT.yaml +4 -0
- package/.project/IDEAS.yaml +7 -0
- package/.project/PROJECT.yaml +11 -0
- package/.project/ROADMAP.yaml +15 -0
- package/CHANGELOG.md +8 -0
- package/CODE_OF_CONDUCT.md +16 -0
- package/CONTRIBUTING.md +26 -0
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/SECURITY.md +15 -0
- package/SUPPORT.md +8 -0
- package/package.json +75 -0
- package/packages/convex/package.json +42 -0
- package/packages/convex/src/functions.ts +5 -0
- package/packages/convex/src/mutations.ts +83 -0
- package/packages/convex/src/queries.ts +57 -0
- package/packages/convex/src/schema.ts +23 -0
- package/packages/convex/tsconfig.json +19 -0
- package/packages/convex/tsup.config.ts +18 -0
- package/packages/react/README.md +1 -0
- package/packages/react/package.json +49 -0
- package/packages/react/src/components/ArticleJsonLd.tsx +42 -0
- package/packages/react/src/components/BreadcrumbsJsonLd.tsx +24 -0
- package/packages/react/src/components/MetaEditor.tsx +147 -0
- package/packages/react/src/components/SEOHead.tsx +107 -0
- package/packages/react/src/components/SEOPreview.tsx +42 -0
- package/packages/react/src/components/SEOScoreCard.tsx +51 -0
- package/packages/react/src/components/SitemapViewer.tsx +36 -0
- package/packages/react/src/components/index.ts +7 -0
- package/packages/react/src/hooks/index.ts +4 -0
- package/packages/react/src/hooks/useSEO.ts +27 -0
- package/packages/react/src/hooks/useSEOAdmin.ts +42 -0
- package/packages/react/src/hooks/useSEOScore.ts +7 -0
- package/packages/react/src/hooks/useSitemap.ts +8 -0
- package/packages/react/src/index.ts +51 -0
- package/packages/react/src/index.tsx +11 -0
- package/packages/react/src/pages/SEOAdminPage.tsx +101 -0
- package/packages/react/src/pages/SEOAnalyticsPage.tsx +96 -0
- package/packages/react/src/pages/index.ts +2 -0
- package/packages/react/tsconfig.json +19 -0
- package/packages/react/tsup.config.ts +12 -0
- package/packages/react-css/README.md +1 -0
- package/packages/react-css/package.json +36 -0
- package/packages/react-css/src/components/ArticleJsonLd.tsx +42 -0
- package/packages/react-css/src/components/BreadcrumbsJsonLd.tsx +24 -0
- package/packages/react-css/src/components/MetaEditor.tsx +147 -0
- package/packages/react-css/src/components/SEOHead.tsx +95 -0
- package/packages/react-css/src/components/SEOPreview.tsx +42 -0
- package/packages/react-css/src/components/SEOScoreCard.tsx +42 -0
- package/packages/react-css/src/components/SitemapViewer.tsx +36 -0
- package/packages/react-css/src/components/index.ts +7 -0
- package/packages/react-css/src/index.ts +9 -0
- package/packages/react-css/src/pages/SEOAdminPage.tsx +88 -0
- package/packages/react-css/src/pages/SEOAnalyticsPage.tsx +82 -0
- package/packages/react-css/src/pages/index.ts +2 -0
- package/packages/react-css/src/seo.css +650 -0
- package/packages/react-css/tsup.config.ts +2 -0
- package/packages/shared/README.md +1 -0
- package/packages/shared/package.json +42 -0
- package/packages/shared/src/__tests__/seo.test.ts +70 -0
- package/packages/shared/src/config.ts +297 -0
- package/packages/shared/src/index.ts +207 -0
- package/packages/shared/tsconfig.json +18 -0
- package/packages/shared/tsup.config.ts +11 -0
- package/packages/shared/vitest.config.ts +4 -0
- package/packages/solidjs/README.md +1 -0
- package/packages/solidjs/package.json +45 -0
- package/packages/solidjs/src/components/ArticleJsonLd.tsx +35 -0
- package/packages/solidjs/src/components/BreadcrumbsJsonLd.tsx +24 -0
- package/packages/solidjs/src/components/MetaEditor.tsx +155 -0
- package/packages/solidjs/src/components/SEOHead.tsx +109 -0
- package/packages/solidjs/src/components/SEOPreview.tsx +42 -0
- package/packages/solidjs/src/components/SEOScoreCard.tsx +57 -0
- package/packages/solidjs/src/components/SitemapViewer.tsx +44 -0
- package/packages/solidjs/src/components/index.ts +7 -0
- package/packages/solidjs/src/index.ts +11 -0
- package/packages/solidjs/src/pages/SEOAdminPage.tsx +104 -0
- package/packages/solidjs/src/pages/SEOAnalyticsPage.tsx +102 -0
- package/packages/solidjs/src/pages/index.ts +2 -0
- package/packages/solidjs/src/primitives/index.ts +4 -0
- package/packages/solidjs/src/primitives/useSEO.ts +27 -0
- package/packages/solidjs/src/primitives/useSEOAdmin.ts +42 -0
- package/packages/solidjs/src/primitives/useSEOScore.ts +7 -0
- package/packages/solidjs/src/primitives/useSitemap.ts +8 -0
- package/packages/solidjs/tsconfig.json +20 -0
- package/packages/solidjs/tsup.config.ts +12 -0
- package/packages/solidjs-css/README.md +1 -0
- package/packages/solidjs-css/package.json +35 -0
- package/packages/solidjs-css/src/index.ts +5 -0
- package/packages/solidjs-css/src/primitives/index.ts +1 -0
- package/packages/solidjs-css/src/seo.css +650 -0
- package/packages/solidjs-css/tsup.config.ts +2 -0
- package/pnpm-workspace.yaml +2 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { useSEOAdmin } from '../hooks/useSEOAdmin'
|
|
3
|
+
import { MetaEditor } from '../components/MetaEditor'
|
|
4
|
+
import { SEOPreview } from '../components/SEOPreview'
|
|
5
|
+
import { SEOScoreCard } from '../components/SEOScoreCard'
|
|
6
|
+
import type { SEOMeta } from '@geenius-seo/shared'
|
|
7
|
+
|
|
8
|
+
export function SEOAdminPage() {
|
|
9
|
+
const { pages, upsertMeta, deleteMeta, isLoading } = useSEOAdmin()
|
|
10
|
+
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
|
11
|
+
const [editingMeta, setEditingMeta] = useState<SEOMeta | null>(null)
|
|
12
|
+
|
|
13
|
+
const currentPage = pages.find((p) => p.path === selectedPath)
|
|
14
|
+
|
|
15
|
+
const handleSave = async () => {
|
|
16
|
+
if (selectedPath && editingMeta) {
|
|
17
|
+
await upsertMeta(selectedPath, editingMeta)
|
|
18
|
+
setEditingMeta(null)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const handleDelete = async () => {
|
|
23
|
+
if (selectedPath) {
|
|
24
|
+
await deleteMeta(selectedPath)
|
|
25
|
+
setSelectedPath(null)
|
|
26
|
+
setEditingMeta(null)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (isLoading) return <div className="p-4">Loading...</div>
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="max-w-6xl mx-auto p-6">
|
|
34
|
+
<h1 className="text-3xl font-bold mb-6">SEO Admin</h1>
|
|
35
|
+
|
|
36
|
+
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
37
|
+
{/* Pages List */}
|
|
38
|
+
<div className="lg:col-span-1 border border-gray-200 rounded p-4">
|
|
39
|
+
<h2 className="font-semibold mb-4">Pages</h2>
|
|
40
|
+
<div className="space-y-2">
|
|
41
|
+
{pages.map((page) => (
|
|
42
|
+
<button
|
|
43
|
+
key={page.path}
|
|
44
|
+
onClick={() => {
|
|
45
|
+
setSelectedPath(page.path)
|
|
46
|
+
setEditingMeta(page.meta)
|
|
47
|
+
}}
|
|
48
|
+
className={`w-full text-left p-2 rounded text-sm transition ${
|
|
49
|
+
selectedPath === page.path
|
|
50
|
+
? 'bg-blue-100 text-blue-900'
|
|
51
|
+
: 'hover:bg-gray-100'
|
|
52
|
+
}`}
|
|
53
|
+
>
|
|
54
|
+
{page.path || '/'}
|
|
55
|
+
</button>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
{/* Editor and Preview */}
|
|
61
|
+
{currentPage && editingMeta && (
|
|
62
|
+
<div className="lg:col-span-3 space-y-6">
|
|
63
|
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
64
|
+
<div>
|
|
65
|
+
<h2 className="font-semibold mb-4">Edit Metadata</h2>
|
|
66
|
+
<MetaEditor
|
|
67
|
+
meta={editingMeta}
|
|
68
|
+
onChange={setEditingMeta}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div className="space-y-6">
|
|
73
|
+
<div>
|
|
74
|
+
<h2 className="font-semibold mb-4">Preview</h2>
|
|
75
|
+
<SEOPreview meta={editingMeta} />
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<SEOScoreCard meta={editingMeta} />
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="flex gap-2">
|
|
83
|
+
<button
|
|
84
|
+
onClick={handleSave}
|
|
85
|
+
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
|
86
|
+
>
|
|
87
|
+
Save
|
|
88
|
+
</button>
|
|
89
|
+
<button
|
|
90
|
+
onClick={handleDelete}
|
|
91
|
+
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
|
92
|
+
>
|
|
93
|
+
Delete
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
interface AnalyticsSummary {
|
|
4
|
+
path: string
|
|
5
|
+
views: number
|
|
6
|
+
avgTimeOnPage: number
|
|
7
|
+
bounceRate: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function SEOAnalyticsPage() {
|
|
11
|
+
const [topPages, setTopPages] = useState<AnalyticsSummary[]>([])
|
|
12
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
setIsLoading(true)
|
|
16
|
+
// Placeholder for Convex query integration
|
|
17
|
+
// const result = await client.query(api.queries.getTopPages, { limit: 10 })
|
|
18
|
+
// setTopPages(result)
|
|
19
|
+
setIsLoading(false)
|
|
20
|
+
}, [])
|
|
21
|
+
|
|
22
|
+
if (isLoading) return <div className="p-4">Loading...</div>
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="max-w-6xl mx-auto p-6">
|
|
26
|
+
<h1 className="text-3xl font-bold mb-6">SEO Analytics</h1>
|
|
27
|
+
|
|
28
|
+
<div className="space-y-6">
|
|
29
|
+
{/* Top Pages */}
|
|
30
|
+
<div className="border border-gray-200 rounded p-4">
|
|
31
|
+
<h2 className="font-semibold mb-4">Top Pages by Views</h2>
|
|
32
|
+
|
|
33
|
+
<div className="overflow-x-auto">
|
|
34
|
+
<table className="w-full text-sm">
|
|
35
|
+
<thead className="bg-gray-100 border-b">
|
|
36
|
+
<tr>
|
|
37
|
+
<th className="text-left p-3">Page</th>
|
|
38
|
+
<th className="text-right p-3">Views</th>
|
|
39
|
+
<th className="text-right p-3">Avg Time (s)</th>
|
|
40
|
+
<th className="text-right p-3">Bounce Rate</th>
|
|
41
|
+
</tr>
|
|
42
|
+
</thead>
|
|
43
|
+
<tbody>
|
|
44
|
+
{topPages.map((page, i) => (
|
|
45
|
+
<tr key={i} className="border-b hover:bg-gray-50">
|
|
46
|
+
<td className="p-3 font-mono text-xs">{page.path}</td>
|
|
47
|
+
<td className="p-3 text-right">{page.views.toLocaleString()}</td>
|
|
48
|
+
<td className="p-3 text-right">{page.avgTimeOnPage.toFixed(1)}</td>
|
|
49
|
+
<td className="p-3 text-right">{(page.bounceRate * 100).toFixed(1)}%</td>
|
|
50
|
+
</tr>
|
|
51
|
+
))}
|
|
52
|
+
</tbody>
|
|
53
|
+
</table>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{topPages.length === 0 && (
|
|
57
|
+
<p className="text-center text-gray-500 py-8">No analytics data available</p>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
{/* Analytics Summary */}
|
|
62
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
63
|
+
<div className="border border-gray-200 rounded p-4">
|
|
64
|
+
<h3 className="text-sm text-gray-600 mb-1">Total Views</h3>
|
|
65
|
+
<p className="text-2xl font-bold">
|
|
66
|
+
{topPages.reduce((sum, p) => sum + p.views, 0).toLocaleString()}
|
|
67
|
+
</p>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="border border-gray-200 rounded p-4">
|
|
71
|
+
<h3 className="text-sm text-gray-600 mb-1">Average Time on Page</h3>
|
|
72
|
+
<p className="text-2xl font-bold">
|
|
73
|
+
{topPages.length > 0
|
|
74
|
+
? (topPages.reduce((sum, p) => sum + p.avgTimeOnPage, 0) / topPages.length).toFixed(1)
|
|
75
|
+
: '0'}
|
|
76
|
+
s
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div className="border border-gray-200 rounded p-4">
|
|
81
|
+
<h3 className="text-sm text-gray-600 mb-1">Average Bounce Rate</h3>
|
|
82
|
+
<p className="text-2xl font-bold">
|
|
83
|
+
{topPages.length > 0
|
|
84
|
+
? (
|
|
85
|
+
(topPages.reduce((sum, p) => sum + p.bounceRate, 0) / topPages.length) *
|
|
86
|
+
100
|
|
87
|
+
).toFixed(1)
|
|
88
|
+
: '0'}
|
|
89
|
+
%
|
|
90
|
+
</p>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "dist",
|
|
5
|
+
"rootDir": "src",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"target": "ES2022",
|
|
13
|
+
"module": "ESNext",
|
|
14
|
+
"moduleResolution": "bundler"
|
|
15
|
+
},
|
|
16
|
+
"include": [
|
|
17
|
+
"src"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# ✦ @geenius-seo/react-css\n\n> A premium module for the Geenius Boilerplate Ecosystem.\n\n---\n\n## Overview\nBuilt with Steve Jobs-level minimalism and Jony Ive-level craftsmanship, this package is designed to deliver unparalleled developer experience (DX) and rock-solid performance.\n\n## Installation\n\n```bash\npnpm add @geenius-seo/react-css\n```\n\n## Usage\n\n```typescript\nimport { init } from '@geenius-seo/react-css';\n\n// Initialize the module with absolute precision\ninit({\n mode: 'premium',\n});\n```\n\n## Architecture\n- **Zero-config**: It just works.\n- **Strictly Typed**: Fully written in TypeScript for flawless IntelliSense.\n- **Framework Agnostic**: seamlessly integrates into the Geenius ecosystem.\n\n---\n\n*Designed by Antigravity HQ*\n
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@geenius-seo/react-css",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Geenius SEO — react components with vanilla CSS",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.mjs",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsup",
|
|
18
|
+
"lint": "tsc --noEmit",
|
|
19
|
+
"clean": "rm -rf dist"
|
|
20
|
+
},
|
|
21
|
+
"files": ["dist"],
|
|
22
|
+
"publishConfig": { "access": "public" },
|
|
23
|
+
"peerDependencies": { "react": ">=18.0.0" },
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@geenius-seo/shared": "workspace:*"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/react": "^19.0.0",
|
|
29
|
+
"react": "^19.2.4",
|
|
30
|
+
"tsup": "^8.5.1",
|
|
31
|
+
"typescript": "~6.0.2"
|
|
32
|
+
},
|
|
33
|
+
"author": "Antigravity HQ",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"engines": { "node": ">=20.0.0" }
|
|
36
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { articleSchema } from '@geenius-seo/shared'
|
|
3
|
+
|
|
4
|
+
interface ArticleJsonLdProps {
|
|
5
|
+
title: string
|
|
6
|
+
description: string
|
|
7
|
+
author: string
|
|
8
|
+
datePublished: string
|
|
9
|
+
url: string
|
|
10
|
+
image?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ArticleJsonLd({
|
|
14
|
+
title,
|
|
15
|
+
description,
|
|
16
|
+
author,
|
|
17
|
+
datePublished,
|
|
18
|
+
url,
|
|
19
|
+
image,
|
|
20
|
+
}: ArticleJsonLdProps) {
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const script = document.createElement('script')
|
|
23
|
+
script.type = 'application/ld+json'
|
|
24
|
+
script.textContent = JSON.stringify(
|
|
25
|
+
articleSchema({
|
|
26
|
+
title,
|
|
27
|
+
description,
|
|
28
|
+
author,
|
|
29
|
+
datePublished,
|
|
30
|
+
url,
|
|
31
|
+
image,
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
document.head.appendChild(script)
|
|
35
|
+
|
|
36
|
+
return () => {
|
|
37
|
+
document.head.removeChild(script)
|
|
38
|
+
}
|
|
39
|
+
}, [title, description, author, datePublished, url, image])
|
|
40
|
+
|
|
41
|
+
return <div className="seo__article-jsonld" />
|
|
42
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { breadcrumbSchema } from '@geenius-seo/shared'
|
|
3
|
+
|
|
4
|
+
interface BreadcrumbsJsonLdProps {
|
|
5
|
+
items: {
|
|
6
|
+
name: string
|
|
7
|
+
url: string
|
|
8
|
+
}[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function BreadcrumbsJsonLd({ items }: BreadcrumbsJsonLdProps) {
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const script = document.createElement('script')
|
|
14
|
+
script.type = 'application/ld+json'
|
|
15
|
+
script.textContent = JSON.stringify(breadcrumbSchema(items))
|
|
16
|
+
document.head.appendChild(script)
|
|
17
|
+
|
|
18
|
+
return () => {
|
|
19
|
+
document.head.removeChild(script)
|
|
20
|
+
}
|
|
21
|
+
}, [items])
|
|
22
|
+
|
|
23
|
+
return <div className="seo__breadcrumbs" />
|
|
24
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import type { SEOMeta } from '@geenius-seo/shared'
|
|
3
|
+
|
|
4
|
+
interface MetaEditorProps {
|
|
5
|
+
meta: SEOMeta
|
|
6
|
+
onChange: (meta: SEOMeta) => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function MetaEditor({ meta, onChange }: MetaEditorProps) {
|
|
10
|
+
const [localMeta, setLocalMeta] = useState(meta)
|
|
11
|
+
|
|
12
|
+
const handleChange = (updates: Partial<SEOMeta>) => {
|
|
13
|
+
const updated = { ...localMeta, ...updates }
|
|
14
|
+
setLocalMeta(updated)
|
|
15
|
+
onChange(updated)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="seo__meta-editor">
|
|
20
|
+
<div className="seo__meta-field">
|
|
21
|
+
<label className="seo__meta-field-label">
|
|
22
|
+
Title ({localMeta.title.length}/60)
|
|
23
|
+
</label>
|
|
24
|
+
<input
|
|
25
|
+
type="text"
|
|
26
|
+
value={localMeta.title}
|
|
27
|
+
onChange={(e) =>
|
|
28
|
+
handleChange({
|
|
29
|
+
title: e.target.value.slice(0, 60),
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
className="seo__meta-field-input"
|
|
33
|
+
placeholder="Page title"
|
|
34
|
+
/>
|
|
35
|
+
<div className="seo__meta-char-count">
|
|
36
|
+
{localMeta.title.length < 30
|
|
37
|
+
? 'Too short'
|
|
38
|
+
: localMeta.title.length > 60
|
|
39
|
+
? 'Too long'
|
|
40
|
+
: 'Good length'}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div className="seo__meta-field">
|
|
45
|
+
<label className="seo__meta-field-label">
|
|
46
|
+
Description ({localMeta.description.length}/160)
|
|
47
|
+
</label>
|
|
48
|
+
<textarea
|
|
49
|
+
value={localMeta.description}
|
|
50
|
+
onChange={(e) =>
|
|
51
|
+
handleChange({
|
|
52
|
+
description: e.target.value.slice(0, 160),
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
className="seo__meta-field-textarea"
|
|
56
|
+
placeholder="Page description"
|
|
57
|
+
/>
|
|
58
|
+
<div className="seo__meta-char-count">
|
|
59
|
+
{localMeta.description.length < 50
|
|
60
|
+
? 'Too short'
|
|
61
|
+
: localMeta.description.length > 160
|
|
62
|
+
? 'Too long'
|
|
63
|
+
: 'Good length'}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div className="seo__meta-field">
|
|
68
|
+
<label className="seo__meta-field-label">Keywords</label>
|
|
69
|
+
<input
|
|
70
|
+
type="text"
|
|
71
|
+
value={localMeta.keywords.join(', ')}
|
|
72
|
+
onChange={(e) =>
|
|
73
|
+
handleChange({
|
|
74
|
+
keywords: e.target.value.split(',').map((k) => k.trim()),
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
className="seo__meta-field-input"
|
|
78
|
+
placeholder="keyword1, keyword2, keyword3"
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="seo__meta-field">
|
|
83
|
+
<label className="seo__meta-field-label">Canonical URL</label>
|
|
84
|
+
<input
|
|
85
|
+
type="url"
|
|
86
|
+
value={localMeta.canonical || ''}
|
|
87
|
+
onChange={(e) =>
|
|
88
|
+
handleChange({
|
|
89
|
+
canonical: e.target.value,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
className="seo__meta-field-input"
|
|
93
|
+
placeholder="https://example.com/page"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div className="seo__meta-field">
|
|
98
|
+
<label className="seo__meta-field-label">OG Image URL</label>
|
|
99
|
+
<input
|
|
100
|
+
type="url"
|
|
101
|
+
value={localMeta.og.image || ''}
|
|
102
|
+
onChange={(e) =>
|
|
103
|
+
handleChange({
|
|
104
|
+
og: { ...localMeta.og, image: e.target.value },
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
className="seo__meta-field-input"
|
|
108
|
+
placeholder="https://example.com/image.jpg"
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div className="seo__meta-field">
|
|
113
|
+
<label className="seo__meta-field-label">Twitter Card Type</label>
|
|
114
|
+
<select
|
|
115
|
+
value={localMeta.twitter.card}
|
|
116
|
+
onChange={(e) =>
|
|
117
|
+
handleChange({
|
|
118
|
+
twitter: {
|
|
119
|
+
...localMeta.twitter,
|
|
120
|
+
card: e.target.value as 'summary' | 'summary_large_image',
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
className="seo__meta-field-select"
|
|
125
|
+
>
|
|
126
|
+
<option value="summary">Summary</option>
|
|
127
|
+
<option value="summary_large_image">Summary Large Image</option>
|
|
128
|
+
</select>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div className="seo__meta-field">
|
|
132
|
+
<label className="seo__meta-field-label">Robots Directive</label>
|
|
133
|
+
<input
|
|
134
|
+
type="text"
|
|
135
|
+
value={localMeta.robots || ''}
|
|
136
|
+
onChange={(e) =>
|
|
137
|
+
handleChange({
|
|
138
|
+
robots: e.target.value,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
className="seo__meta-field-input"
|
|
142
|
+
placeholder="index, follow"
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import type { SEOMeta } from '@geenius-seo/shared'
|
|
3
|
+
|
|
4
|
+
interface SEOHeadProps {
|
|
5
|
+
meta: SEOMeta
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function SEOHead({ meta }: SEOHeadProps) {
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
document.title = meta.title
|
|
11
|
+
|
|
12
|
+
const setMetaTag = (name: string, content: string, isProperty = false) => {
|
|
13
|
+
let tag = document.querySelector(
|
|
14
|
+
`meta[${isProperty ? 'property' : 'name'}="${name}"]`,
|
|
15
|
+
) as HTMLMetaElement
|
|
16
|
+
if (!tag) {
|
|
17
|
+
tag = document.createElement('meta')
|
|
18
|
+
isProperty ? tag.setAttribute('property', name) : tag.setAttribute('name', name)
|
|
19
|
+
document.head.appendChild(tag)
|
|
20
|
+
}
|
|
21
|
+
tag.content = content
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
setMetaTag('description', meta.description)
|
|
25
|
+
if (meta.keywords.length > 0) {
|
|
26
|
+
setMetaTag('keywords', meta.keywords.join(', '))
|
|
27
|
+
}
|
|
28
|
+
if (meta.robots) {
|
|
29
|
+
setMetaTag('robots', meta.robots)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
setMetaTag('og:title', meta.og.title, true)
|
|
33
|
+
setMetaTag('og:description', meta.og.description, true)
|
|
34
|
+
if (meta.og.image) {
|
|
35
|
+
setMetaTag('og:image', meta.og.image, true)
|
|
36
|
+
if (meta.og.imageAlt) {
|
|
37
|
+
setMetaTag('og:image:alt', meta.og.imageAlt, true)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
setMetaTag('og:type', meta.og.type, true)
|
|
41
|
+
setMetaTag('og:url', meta.og.url, true)
|
|
42
|
+
if (meta.og.siteName) {
|
|
43
|
+
setMetaTag('og:site_name', meta.og.siteName, true)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
setMetaTag('twitter:card', meta.twitter.card)
|
|
47
|
+
setMetaTag('twitter:title', meta.twitter.title)
|
|
48
|
+
setMetaTag('twitter:description', meta.twitter.description)
|
|
49
|
+
if (meta.twitter.image) {
|
|
50
|
+
setMetaTag('twitter:image', meta.twitter.image)
|
|
51
|
+
}
|
|
52
|
+
if (meta.twitter.creator) {
|
|
53
|
+
setMetaTag('twitter:creator', meta.twitter.creator)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (meta.canonical) {
|
|
57
|
+
let canonicalLink = document.querySelector('link[rel="canonical"]') as HTMLLinkElement
|
|
58
|
+
if (!canonicalLink) {
|
|
59
|
+
canonicalLink = document.createElement('link')
|
|
60
|
+
canonicalLink.rel = 'canonical'
|
|
61
|
+
document.head.appendChild(canonicalLink)
|
|
62
|
+
}
|
|
63
|
+
canonicalLink.href = meta.canonical
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (meta.alternates) {
|
|
67
|
+
Object.entries(meta.alternates).forEach(([lang, url]) => {
|
|
68
|
+
let altLink = document.querySelector(
|
|
69
|
+
`link[rel="alternate"][hreflang="${lang}"]`,
|
|
70
|
+
) as HTMLLinkElement
|
|
71
|
+
if (!altLink) {
|
|
72
|
+
altLink = document.createElement('link')
|
|
73
|
+
altLink.rel = 'alternate'
|
|
74
|
+
altLink.hrefLang = lang
|
|
75
|
+
document.head.appendChild(altLink)
|
|
76
|
+
}
|
|
77
|
+
altLink.href = url
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (meta.jsonLd && meta.jsonLd.length > 0) {
|
|
82
|
+
meta.jsonLd.forEach((schema) => {
|
|
83
|
+
let script = document.querySelector('script[type="application/ld+json"]') as HTMLScriptElement
|
|
84
|
+
if (!script) {
|
|
85
|
+
script = document.createElement('script')
|
|
86
|
+
script.type = 'application/ld+json'
|
|
87
|
+
document.head.appendChild(script)
|
|
88
|
+
}
|
|
89
|
+
script.textContent = JSON.stringify(schema)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}, [meta])
|
|
93
|
+
|
|
94
|
+
return <div className="seo__head" />
|
|
95
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SEOMeta } from '@geenius-seo/shared'
|
|
2
|
+
|
|
3
|
+
interface SEOPreviewProps {
|
|
4
|
+
meta: SEOMeta
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function SEOPreview({ meta }: SEOPreviewProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="seo__preview">
|
|
10
|
+
<div>
|
|
11
|
+
<h3 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.5rem' }}>
|
|
12
|
+
Google Search Preview
|
|
13
|
+
</h3>
|
|
14
|
+
<div className="seo__preview-google">
|
|
15
|
+
<div className="seo__preview-google-url">{new URL(meta.og.url).hostname}</div>
|
|
16
|
+
<div className="seo__preview-google-title">{meta.title}</div>
|
|
17
|
+
<div className="seo__preview-google-desc">{meta.description}</div>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div>
|
|
22
|
+
<h3 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.5rem' }}>
|
|
23
|
+
Social Media Preview
|
|
24
|
+
</h3>
|
|
25
|
+
<div className="seo__preview-social">
|
|
26
|
+
{meta.og.image && (
|
|
27
|
+
<img
|
|
28
|
+
src={meta.og.image}
|
|
29
|
+
alt={meta.og.imageAlt || meta.og.title}
|
|
30
|
+
className="seo__preview-social-image"
|
|
31
|
+
/>
|
|
32
|
+
)}
|
|
33
|
+
<div className="seo__preview-social-info">
|
|
34
|
+
<div className="seo__preview-social-title">{meta.og.title}</div>
|
|
35
|
+
<div className="seo__preview-social-desc">{meta.og.description}</div>
|
|
36
|
+
<div className="seo__preview-social-url">{meta.og.url}</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useSEOScore } from '@geenius-seo/react'
|
|
2
|
+
import type { SEOMeta } from '@geenius-seo/shared'
|
|
3
|
+
|
|
4
|
+
interface SEOScoreCardProps {
|
|
5
|
+
meta: SEOMeta
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function SEOScoreCard({ meta }: SEOScoreCardProps) {
|
|
9
|
+
const { score, issues } = useSEOScore(meta)
|
|
10
|
+
|
|
11
|
+
const scoreClass =
|
|
12
|
+
score >= 80 ? 'seo__score-number--good' : score >= 50 ? 'seo__score-number--fair' : 'seo__score-number--poor'
|
|
13
|
+
const barClass =
|
|
14
|
+
score >= 80 ? 'seo__score-bar-fill--good' : score >= 50 ? 'seo__score-bar-fill--fair' : 'seo__score-bar-fill--poor'
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<div className="seo__score-card">
|
|
18
|
+
<div className="seo__score-card-header">
|
|
19
|
+
<div className="seo__score-card-title">SEO Score</div>
|
|
20
|
+
<span className={`seo__score-number ${scoreClass}`}>{score}/100</span>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div className="seo__score-bar">
|
|
24
|
+
<div className={`seo__score-bar-fill ${barClass}`} style={{ width: `${score}%` }} />
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
{issues.length > 0 && (
|
|
28
|
+
<div>
|
|
29
|
+
<div className="seo__issue-list-title">Issues Found</div>
|
|
30
|
+
<ul className="seo__issue-list">
|
|
31
|
+
{issues.map((issue, i) => (
|
|
32
|
+
<li key={i} className="seo__issue-item">
|
|
33
|
+
<span className="seo__issue-item-bullet">•</span>
|
|
34
|
+
{issue}
|
|
35
|
+
</li>
|
|
36
|
+
))}
|
|
37
|
+
</ul>
|
|
38
|
+
</div>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|