@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.
Files changed (103) hide show
  1. package/.changeset/config.json +11 -0
  2. package/.github/CODEOWNERS +1 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +16 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.md +11 -0
  5. package/.github/PULL_REQUEST_TEMPLATE.md +10 -0
  6. package/.github/dependabot.yml +11 -0
  7. package/.github/workflows/ci.yml +23 -0
  8. package/.github/workflows/release.yml +29 -0
  9. package/.nvmrc +1 -0
  10. package/.project/ACCOUNT.yaml +4 -0
  11. package/.project/IDEAS.yaml +7 -0
  12. package/.project/PROJECT.yaml +11 -0
  13. package/.project/ROADMAP.yaml +15 -0
  14. package/CHANGELOG.md +8 -0
  15. package/CODE_OF_CONDUCT.md +16 -0
  16. package/CONTRIBUTING.md +26 -0
  17. package/LICENSE +21 -0
  18. package/README.md +1 -0
  19. package/SECURITY.md +15 -0
  20. package/SUPPORT.md +8 -0
  21. package/package.json +75 -0
  22. package/packages/convex/package.json +42 -0
  23. package/packages/convex/src/functions.ts +5 -0
  24. package/packages/convex/src/mutations.ts +83 -0
  25. package/packages/convex/src/queries.ts +57 -0
  26. package/packages/convex/src/schema.ts +23 -0
  27. package/packages/convex/tsconfig.json +19 -0
  28. package/packages/convex/tsup.config.ts +18 -0
  29. package/packages/react/README.md +1 -0
  30. package/packages/react/package.json +49 -0
  31. package/packages/react/src/components/ArticleJsonLd.tsx +42 -0
  32. package/packages/react/src/components/BreadcrumbsJsonLd.tsx +24 -0
  33. package/packages/react/src/components/MetaEditor.tsx +147 -0
  34. package/packages/react/src/components/SEOHead.tsx +107 -0
  35. package/packages/react/src/components/SEOPreview.tsx +42 -0
  36. package/packages/react/src/components/SEOScoreCard.tsx +51 -0
  37. package/packages/react/src/components/SitemapViewer.tsx +36 -0
  38. package/packages/react/src/components/index.ts +7 -0
  39. package/packages/react/src/hooks/index.ts +4 -0
  40. package/packages/react/src/hooks/useSEO.ts +27 -0
  41. package/packages/react/src/hooks/useSEOAdmin.ts +42 -0
  42. package/packages/react/src/hooks/useSEOScore.ts +7 -0
  43. package/packages/react/src/hooks/useSitemap.ts +8 -0
  44. package/packages/react/src/index.ts +51 -0
  45. package/packages/react/src/index.tsx +11 -0
  46. package/packages/react/src/pages/SEOAdminPage.tsx +101 -0
  47. package/packages/react/src/pages/SEOAnalyticsPage.tsx +96 -0
  48. package/packages/react/src/pages/index.ts +2 -0
  49. package/packages/react/tsconfig.json +19 -0
  50. package/packages/react/tsup.config.ts +12 -0
  51. package/packages/react-css/README.md +1 -0
  52. package/packages/react-css/package.json +36 -0
  53. package/packages/react-css/src/components/ArticleJsonLd.tsx +42 -0
  54. package/packages/react-css/src/components/BreadcrumbsJsonLd.tsx +24 -0
  55. package/packages/react-css/src/components/MetaEditor.tsx +147 -0
  56. package/packages/react-css/src/components/SEOHead.tsx +95 -0
  57. package/packages/react-css/src/components/SEOPreview.tsx +42 -0
  58. package/packages/react-css/src/components/SEOScoreCard.tsx +42 -0
  59. package/packages/react-css/src/components/SitemapViewer.tsx +36 -0
  60. package/packages/react-css/src/components/index.ts +7 -0
  61. package/packages/react-css/src/index.ts +9 -0
  62. package/packages/react-css/src/pages/SEOAdminPage.tsx +88 -0
  63. package/packages/react-css/src/pages/SEOAnalyticsPage.tsx +82 -0
  64. package/packages/react-css/src/pages/index.ts +2 -0
  65. package/packages/react-css/src/seo.css +650 -0
  66. package/packages/react-css/tsup.config.ts +2 -0
  67. package/packages/shared/README.md +1 -0
  68. package/packages/shared/package.json +42 -0
  69. package/packages/shared/src/__tests__/seo.test.ts +70 -0
  70. package/packages/shared/src/config.ts +297 -0
  71. package/packages/shared/src/index.ts +207 -0
  72. package/packages/shared/tsconfig.json +18 -0
  73. package/packages/shared/tsup.config.ts +11 -0
  74. package/packages/shared/vitest.config.ts +4 -0
  75. package/packages/solidjs/README.md +1 -0
  76. package/packages/solidjs/package.json +45 -0
  77. package/packages/solidjs/src/components/ArticleJsonLd.tsx +35 -0
  78. package/packages/solidjs/src/components/BreadcrumbsJsonLd.tsx +24 -0
  79. package/packages/solidjs/src/components/MetaEditor.tsx +155 -0
  80. package/packages/solidjs/src/components/SEOHead.tsx +109 -0
  81. package/packages/solidjs/src/components/SEOPreview.tsx +42 -0
  82. package/packages/solidjs/src/components/SEOScoreCard.tsx +57 -0
  83. package/packages/solidjs/src/components/SitemapViewer.tsx +44 -0
  84. package/packages/solidjs/src/components/index.ts +7 -0
  85. package/packages/solidjs/src/index.ts +11 -0
  86. package/packages/solidjs/src/pages/SEOAdminPage.tsx +104 -0
  87. package/packages/solidjs/src/pages/SEOAnalyticsPage.tsx +102 -0
  88. package/packages/solidjs/src/pages/index.ts +2 -0
  89. package/packages/solidjs/src/primitives/index.ts +4 -0
  90. package/packages/solidjs/src/primitives/useSEO.ts +27 -0
  91. package/packages/solidjs/src/primitives/useSEOAdmin.ts +42 -0
  92. package/packages/solidjs/src/primitives/useSEOScore.ts +7 -0
  93. package/packages/solidjs/src/primitives/useSitemap.ts +8 -0
  94. package/packages/solidjs/tsconfig.json +20 -0
  95. package/packages/solidjs/tsup.config.ts +12 -0
  96. package/packages/solidjs-css/README.md +1 -0
  97. package/packages/solidjs-css/package.json +35 -0
  98. package/packages/solidjs-css/src/index.ts +5 -0
  99. package/packages/solidjs-css/src/primitives/index.ts +1 -0
  100. package/packages/solidjs-css/src/seo.css +650 -0
  101. package/packages/solidjs-css/tsup.config.ts +2 -0
  102. package/pnpm-workspace.yaml +2 -0
  103. 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,7 @@
1
+ export * from './SEOHead'
2
+ export * from './SEOPreview'
3
+ export * from './SEOScoreCard'
4
+ export * from './MetaEditor'
5
+ export * from './SitemapViewer'
6
+ export * from './BreadcrumbsJsonLd'
7
+ export * from './ArticleJsonLd'
@@ -0,0 +1,4 @@
1
+ export * from './useSEO'
2
+ export * from './useSEOAdmin'
3
+ export * from './useSEOScore'
4
+ export * from './useSitemap'
@@ -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,7 @@
1
+ import { useMemo } from 'react'
2
+ import { calcSEOScore } from '@geenius-seo/shared'
3
+ import type { SEOMeta } from '@geenius-seo/shared'
4
+
5
+ export function useSEOScore(meta: SEOMeta) {
6
+ return useMemo(() => calcSEOScore(meta), [meta])
7
+ }
@@ -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
+ }
@@ -0,0 +1,11 @@
1
+ // Shared types and utilities
2
+ export * from '@geenius-seo/shared'
3
+
4
+ // Hooks
5
+ export * from './hooks'
6
+
7
+ // Components
8
+ export * from './components'
9
+
10
+ // Pages
11
+ export * from './pages'