@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,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 null
|
|
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 null
|
|
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="space-y-6 p-4 border border-gray-200 rounded">
|
|
20
|
+
{/* Title */}
|
|
21
|
+
<div>
|
|
22
|
+
<label className="block text-sm font-semibold text-gray-900 mb-1">
|
|
23
|
+
Title {localMeta.title.length}/60
|
|
24
|
+
</label>
|
|
25
|
+
<input
|
|
26
|
+
type="text"
|
|
27
|
+
value={localMeta.title}
|
|
28
|
+
onChange={(e) =>
|
|
29
|
+
handleChange({
|
|
30
|
+
title: e.target.value.slice(0, 60),
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
|
34
|
+
placeholder="Page title"
|
|
35
|
+
/>
|
|
36
|
+
<p className="text-xs text-gray-500 mt-1">
|
|
37
|
+
{localMeta.title.length < 30 ? 'Too short' : localMeta.title.length > 60 ? 'Too long' : 'Good length'}
|
|
38
|
+
</p>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
{/* Description */}
|
|
42
|
+
<div>
|
|
43
|
+
<label className="block text-sm font-semibold text-gray-900 mb-1">
|
|
44
|
+
Description {localMeta.description.length}/160
|
|
45
|
+
</label>
|
|
46
|
+
<textarea
|
|
47
|
+
value={localMeta.description}
|
|
48
|
+
onChange={(e) =>
|
|
49
|
+
handleChange({
|
|
50
|
+
description: e.target.value.slice(0, 160),
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
|
54
|
+
rows={3}
|
|
55
|
+
placeholder="Page description"
|
|
56
|
+
/>
|
|
57
|
+
<p className="text-xs text-gray-500 mt-1">
|
|
58
|
+
{localMeta.description.length < 50 ? 'Too short' : localMeta.description.length > 160 ? 'Too long' : 'Good length'}
|
|
59
|
+
</p>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
{/* Keywords */}
|
|
63
|
+
<div>
|
|
64
|
+
<label className="block text-sm font-semibold text-gray-900 mb-1">Keywords</label>
|
|
65
|
+
<input
|
|
66
|
+
type="text"
|
|
67
|
+
value={localMeta.keywords.join(', ')}
|
|
68
|
+
onChange={(e) =>
|
|
69
|
+
handleChange({
|
|
70
|
+
keywords: e.target.value.split(',').map((k) => k.trim()),
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
|
74
|
+
placeholder="keyword1, keyword2, keyword3"
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{/* Canonical */}
|
|
79
|
+
<div>
|
|
80
|
+
<label className="block text-sm font-semibold text-gray-900 mb-1">Canonical URL</label>
|
|
81
|
+
<input
|
|
82
|
+
type="url"
|
|
83
|
+
value={localMeta.canonical || ''}
|
|
84
|
+
onChange={(e) =>
|
|
85
|
+
handleChange({
|
|
86
|
+
canonical: e.target.value,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
|
90
|
+
placeholder="https://example.com/page"
|
|
91
|
+
/>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* OG Image */}
|
|
95
|
+
<div>
|
|
96
|
+
<label className="block text-sm font-semibold text-gray-900 mb-1">OG Image URL</label>
|
|
97
|
+
<input
|
|
98
|
+
type="url"
|
|
99
|
+
value={localMeta.og.image || ''}
|
|
100
|
+
onChange={(e) =>
|
|
101
|
+
handleChange({
|
|
102
|
+
og: { ...localMeta.og, image: e.target.value },
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
|
106
|
+
placeholder="https://example.com/image.jpg"
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Twitter Card */}
|
|
111
|
+
<div>
|
|
112
|
+
<label className="block text-sm font-semibold text-gray-900 mb-1">Twitter Card Type</label>
|
|
113
|
+
<select
|
|
114
|
+
value={localMeta.twitter.card}
|
|
115
|
+
onChange={(e) =>
|
|
116
|
+
handleChange({
|
|
117
|
+
twitter: {
|
|
118
|
+
...localMeta.twitter,
|
|
119
|
+
card: e.target.value as 'summary' | 'summary_large_image',
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
|
124
|
+
>
|
|
125
|
+
<option value="summary">Summary</option>
|
|
126
|
+
<option value="summary_large_image">Summary Large Image</option>
|
|
127
|
+
</select>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* Robots */}
|
|
131
|
+
<div>
|
|
132
|
+
<label className="block text-sm font-semibold text-gray-900 mb-1">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="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
|
142
|
+
placeholder="index, follow"
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
// Set title
|
|
11
|
+
document.title = meta.title
|
|
12
|
+
|
|
13
|
+
// Set meta tags
|
|
14
|
+
const setMetaTag = (name: string, content: string, isProperty = false) => {
|
|
15
|
+
let tag = document.querySelector(
|
|
16
|
+
`meta[${isProperty ? 'property' : 'name'}="${name}"]`,
|
|
17
|
+
) as HTMLMetaElement
|
|
18
|
+
if (!tag) {
|
|
19
|
+
tag = document.createElement('meta')
|
|
20
|
+
isProperty ? tag.setAttribute('property', name) : tag.setAttribute('name', name)
|
|
21
|
+
document.head.appendChild(tag)
|
|
22
|
+
}
|
|
23
|
+
tag.content = content
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Description
|
|
27
|
+
setMetaTag('description', meta.description)
|
|
28
|
+
|
|
29
|
+
// Keywords
|
|
30
|
+
if (meta.keywords.length > 0) {
|
|
31
|
+
setMetaTag('keywords', meta.keywords.join(', '))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Robots
|
|
35
|
+
if (meta.robots) {
|
|
36
|
+
setMetaTag('robots', meta.robots)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// OG tags
|
|
40
|
+
setMetaTag('og:title', meta.og.title, true)
|
|
41
|
+
setMetaTag('og:description', meta.og.description, true)
|
|
42
|
+
if (meta.og.image) {
|
|
43
|
+
setMetaTag('og:image', meta.og.image, true)
|
|
44
|
+
if (meta.og.imageAlt) {
|
|
45
|
+
setMetaTag('og:image:alt', meta.og.imageAlt, true)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
setMetaTag('og:type', meta.og.type, true)
|
|
49
|
+
setMetaTag('og:url', meta.og.url, true)
|
|
50
|
+
if (meta.og.siteName) {
|
|
51
|
+
setMetaTag('og:site_name', meta.og.siteName, true)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Twitter tags
|
|
55
|
+
setMetaTag('twitter:card', meta.twitter.card)
|
|
56
|
+
setMetaTag('twitter:title', meta.twitter.title)
|
|
57
|
+
setMetaTag('twitter:description', meta.twitter.description)
|
|
58
|
+
if (meta.twitter.image) {
|
|
59
|
+
setMetaTag('twitter:image', meta.twitter.image)
|
|
60
|
+
}
|
|
61
|
+
if (meta.twitter.creator) {
|
|
62
|
+
setMetaTag('twitter:creator', meta.twitter.creator)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Canonical
|
|
66
|
+
if (meta.canonical) {
|
|
67
|
+
let canonicalLink = document.querySelector('link[rel="canonical"]') as HTMLLinkElement
|
|
68
|
+
if (!canonicalLink) {
|
|
69
|
+
canonicalLink = document.createElement('link')
|
|
70
|
+
canonicalLink.rel = 'canonical'
|
|
71
|
+
document.head.appendChild(canonicalLink)
|
|
72
|
+
}
|
|
73
|
+
canonicalLink.href = meta.canonical
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Alternates
|
|
77
|
+
if (meta.alternates) {
|
|
78
|
+
Object.entries(meta.alternates).forEach(([lang, url]) => {
|
|
79
|
+
let altLink = document.querySelector(
|
|
80
|
+
`link[rel="alternate"][hreflang="${lang}"]`,
|
|
81
|
+
) as HTMLLinkElement
|
|
82
|
+
if (!altLink) {
|
|
83
|
+
altLink = document.createElement('link')
|
|
84
|
+
altLink.rel = 'alternate'
|
|
85
|
+
altLink.hrefLang = lang
|
|
86
|
+
document.head.appendChild(altLink)
|
|
87
|
+
}
|
|
88
|
+
altLink.href = url
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// JSON-LD
|
|
93
|
+
if (meta.jsonLd && meta.jsonLd.length > 0) {
|
|
94
|
+
meta.jsonLd.forEach((schema) => {
|
|
95
|
+
let script = document.querySelector('script[type="application/ld+json"]') as HTMLScriptElement
|
|
96
|
+
if (!script) {
|
|
97
|
+
script = document.createElement('script')
|
|
98
|
+
script.type = 'application/ld+json'
|
|
99
|
+
document.head.appendChild(script)
|
|
100
|
+
}
|
|
101
|
+
script.textContent = JSON.stringify(schema)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}, [meta])
|
|
105
|
+
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
@@ -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="space-y-6">
|
|
10
|
+
{/* Google SERP Preview */}
|
|
11
|
+
<div className="space-y-2">
|
|
12
|
+
<h3 className="text-sm font-semibold text-gray-700">Google Search Preview</h3>
|
|
13
|
+
<div className="bg-white p-4 rounded border border-gray-200">
|
|
14
|
+
<div className="text-xs text-green-700">{new URL(meta.og.url).hostname}</div>
|
|
15
|
+
<div className="text-lg text-blue-600 hover:underline cursor-pointer truncate">
|
|
16
|
+
{meta.title}
|
|
17
|
+
</div>
|
|
18
|
+
<div className="text-sm text-gray-600 line-clamp-2">{meta.description}</div>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
{/* Social Media Preview */}
|
|
23
|
+
<div className="space-y-2">
|
|
24
|
+
<h3 className="text-sm font-semibold text-gray-700">Social Media Preview</h3>
|
|
25
|
+
<div className="bg-gray-900 rounded overflow-hidden max-w-sm">
|
|
26
|
+
{meta.og.image && (
|
|
27
|
+
<img
|
|
28
|
+
src={meta.og.image}
|
|
29
|
+
alt={meta.og.imageAlt || meta.og.title}
|
|
30
|
+
className="w-full h-48 object-cover"
|
|
31
|
+
/>
|
|
32
|
+
)}
|
|
33
|
+
<div className="p-3 text-white">
|
|
34
|
+
<div className="font-semibold text-sm truncate">{meta.og.title}</div>
|
|
35
|
+
<div className="text-xs text-gray-400 line-clamp-2">{meta.og.description}</div>
|
|
36
|
+
<div className="text-xs text-gray-500 mt-1 truncate">{meta.og.url}</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useSEOScore } from '../hooks/useSEOScore'
|
|
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 getScoreColor = () => {
|
|
12
|
+
if (score >= 80) return 'text-green-600'
|
|
13
|
+
if (score >= 50) return 'text-yellow-600'
|
|
14
|
+
return 'text-red-600'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const getBarColor = () => {
|
|
18
|
+
if (score >= 80) return 'bg-green-500'
|
|
19
|
+
if (score >= 50) return 'bg-yellow-500'
|
|
20
|
+
return 'bg-red-500'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="space-y-4 p-4 border border-gray-200 rounded">
|
|
25
|
+
<div className="flex items-center justify-between">
|
|
26
|
+
<h3 className="font-semibold text-gray-900">SEO Score</h3>
|
|
27
|
+
<span className={`text-2xl font-bold ${getScoreColor()}`}>{score}/100</span>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div className="space-y-2">
|
|
31
|
+
<div className="h-2 bg-gray-200 rounded overflow-hidden">
|
|
32
|
+
<div className={`h-full ${getBarColor()} transition-all`} style={{ width: `${score}%` }} />
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
{issues.length > 0 && (
|
|
37
|
+
<div className="space-y-2">
|
|
38
|
+
<h4 className="text-sm font-semibold text-gray-700">Issues Found</h4>
|
|
39
|
+
<ul className="space-y-1">
|
|
40
|
+
{issues.map((issue, i) => (
|
|
41
|
+
<li key={i} className="text-sm text-red-600 flex items-start">
|
|
42
|
+
<span className="mr-2">•</span>
|
|
43
|
+
{issue}
|
|
44
|
+
</li>
|
|
45
|
+
))}
|
|
46
|
+
</ul>
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { SitemapEntry } from '@geenius-seo/shared'
|
|
2
|
+
|
|
3
|
+
interface SitemapViewerProps {
|
|
4
|
+
entries: SitemapEntry[]
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function SitemapViewer({ entries }: SitemapViewerProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="overflow-x-auto">
|
|
10
|
+
<table className="w-full text-sm">
|
|
11
|
+
<thead className="bg-gray-100 border-b">
|
|
12
|
+
<tr>
|
|
13
|
+
<th className="text-left p-3">URL</th>
|
|
14
|
+
<th className="text-left p-3">Last Modified</th>
|
|
15
|
+
<th className="text-left p-3">Change Frequency</th>
|
|
16
|
+
<th className="text-left p-3">Priority</th>
|
|
17
|
+
</tr>
|
|
18
|
+
</thead>
|
|
19
|
+
<tbody>
|
|
20
|
+
{entries.map((entry, i) => (
|
|
21
|
+
<tr key={i} className="border-b hover:bg-gray-50">
|
|
22
|
+
<td className="p-3 font-mono text-xs text-blue-600 break-all">
|
|
23
|
+
<a href={entry.url} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
|
24
|
+
{entry.url}
|
|
25
|
+
</a>
|
|
26
|
+
</td>
|
|
27
|
+
<td className="p-3 text-gray-600">{entry.lastmod || '-'}</td>
|
|
28
|
+
<td className="p-3 text-gray-600">{entry.changefreq || '-'}</td>
|
|
29
|
+
<td className="p-3 text-gray-600">{entry.priority || '-'}</td>
|
|
30
|
+
</tr>
|
|
31
|
+
))}
|
|
32
|
+
</tbody>
|
|
33
|
+
</table>
|
|
34
|
+
</div>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import type { SEOMeta } from '@geenius-seo/shared'
|
|
3
|
+
|
|
4
|
+
interface UseSEOOptions {
|
|
5
|
+
path?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function useSEO(options?: UseSEOOptions) {
|
|
9
|
+
const [meta, setMeta] = useState<SEOMeta | null>(null)
|
|
10
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
11
|
+
|
|
12
|
+
const updateMeta = (newMeta: SEOMeta) => {
|
|
13
|
+
setMeta(newMeta)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!options?.path) return
|
|
18
|
+
|
|
19
|
+
setIsLoading(true)
|
|
20
|
+
// Placeholder for Convex query integration
|
|
21
|
+
// const meta = await client.query(api.queries.getSEOMeta, { path: options.path })
|
|
22
|
+
// setMeta(meta)
|
|
23
|
+
setIsLoading(false)
|
|
24
|
+
}, [options?.path])
|
|
25
|
+
|
|
26
|
+
return { meta, updateMeta, isLoading }
|
|
27
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react'
|
|
2
|
+
import type { SEOMeta } from '@geenius-seo/shared'
|
|
3
|
+
|
|
4
|
+
interface PageEntry {
|
|
5
|
+
path: string
|
|
6
|
+
meta: SEOMeta
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useSEOAdmin() {
|
|
10
|
+
const [pages, setPages] = useState<PageEntry[]>([])
|
|
11
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
12
|
+
|
|
13
|
+
const upsertMeta = async (path: string, meta: SEOMeta) => {
|
|
14
|
+
// Placeholder for Convex mutation integration
|
|
15
|
+
// await client.mutation(api.mutations.upsertSEOMeta, { path, meta })
|
|
16
|
+
setPages((prev) => {
|
|
17
|
+
const existing = prev.findIndex((p) => p.path === path)
|
|
18
|
+
if (existing >= 0) {
|
|
19
|
+
const next = [...prev]
|
|
20
|
+
next[existing] = { path, meta }
|
|
21
|
+
return next
|
|
22
|
+
}
|
|
23
|
+
return [...prev, { path, meta }]
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const deleteMeta = async (path: string) => {
|
|
28
|
+
// Placeholder for Convex mutation integration
|
|
29
|
+
// await client.mutation(api.mutations.deleteSEOMeta, { path })
|
|
30
|
+
setPages((prev) => prev.filter((p) => p.path !== path))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
setIsLoading(true)
|
|
35
|
+
// Placeholder for Convex query integration
|
|
36
|
+
// const result = await client.query(api.queries.listPages, { limit: 100 })
|
|
37
|
+
// setPages(result)
|
|
38
|
+
setIsLoading(false)
|
|
39
|
+
}, [])
|
|
40
|
+
|
|
41
|
+
return { pages, upsertMeta, deleteMeta, isLoading }
|
|
42
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { generateSitemapXml } from '@geenius-seo/shared'
|
|
3
|
+
import type { SitemapEntry } from '@geenius-seo/shared'
|
|
4
|
+
|
|
5
|
+
export function useSitemap(entries: SitemapEntry[]) {
|
|
6
|
+
const xml = useMemo(() => generateSitemapXml(entries), [entries])
|
|
7
|
+
return { xml }
|
|
8
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import type { SEOMeta, SEOConfig, SitemapEntry } from '@geenius-seo/shared'
|
|
3
|
+
import { buildTitle } from '@geenius-seo/shared'
|
|
4
|
+
|
|
5
|
+
export type { SEOMeta, SEOConfig, SitemapEntry } from '@geenius-seo/shared'
|
|
6
|
+
export { buildTitle, generateSitemapXml, generateRobotsTxt, generateJsonLd, defaultSeoConfig } from '@geenius-seo/shared'
|
|
7
|
+
|
|
8
|
+
export interface UseSEOOptions {
|
|
9
|
+
meta: SEOMeta
|
|
10
|
+
config: SEOConfig
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useSEO(options: UseSEOOptions) {
|
|
14
|
+
const { meta, config } = options
|
|
15
|
+
|
|
16
|
+
const fullTitle = useMemo(() => buildTitle(meta.title, config), [meta.title, config])
|
|
17
|
+
|
|
18
|
+
const tags = useMemo(() => ({
|
|
19
|
+
title: fullTitle,
|
|
20
|
+
meta: [
|
|
21
|
+
{ name: 'description', content: meta.description },
|
|
22
|
+
meta.noIndex && { name: 'robots', content: `${meta.noIndex ? 'noindex' : 'index'},${meta.noFollow ? 'nofollow' : 'follow'}` },
|
|
23
|
+
{ property: 'og:title', content: meta.ogTitle ?? fullTitle },
|
|
24
|
+
{ property: 'og:description', content: meta.ogDescription ?? meta.description },
|
|
25
|
+
meta.ogImage && { property: 'og:image', content: meta.ogImage },
|
|
26
|
+
{ property: 'og:type', content: meta.ogType ?? 'website' },
|
|
27
|
+
{ property: 'og:site_name', content: config.siteName },
|
|
28
|
+
{ name: 'twitter:card', content: meta.twitterCard ?? 'summary_large_image' },
|
|
29
|
+
meta.twitterSite && { name: 'twitter:site', content: meta.twitterSite },
|
|
30
|
+
meta.twitterCreator && { name: 'twitter:creator', content: meta.twitterCreator },
|
|
31
|
+
].filter(Boolean),
|
|
32
|
+
link: [
|
|
33
|
+
meta.canonical && { rel: 'canonical', href: meta.canonical },
|
|
34
|
+
].filter(Boolean),
|
|
35
|
+
jsonLd: meta.jsonLd,
|
|
36
|
+
}), [meta, fullTitle, config])
|
|
37
|
+
|
|
38
|
+
return tags
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SEOHeadProps {
|
|
42
|
+
meta: SEOMeta
|
|
43
|
+
config: SEOConfig
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Note: For full SSR support, integrate with your meta framework (TanStack Start, Next.js, etc.) */
|
|
47
|
+
export function SEOHead(props: SEOHeadProps) {
|
|
48
|
+
const seo = useSEO(props)
|
|
49
|
+
// This renders nothing — consumers should use the hook with their framework's head management
|
|
50
|
+
return null
|
|
51
|
+
}
|