@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,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,2 @@
1
+ export * from './SEOAdminPage'
2
+ export * from './SEOAnalyticsPage'
@@ -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,12 @@
1
+ import { defineConfig } from 'tsup'
2
+
3
+ export default defineConfig({
4
+ entry: { index: 'src/index.ts' },
5
+ outDir: 'dist',
6
+ format: ['esm'],
7
+ dts: true,
8
+ sourcemap: true,
9
+ clean: true,
10
+ treeshake: true,
11
+ external: ['react', 'react-dom'],
12
+ })
@@ -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
+ }