@geenius/seo 0.1.0 → 0.3.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/package.json +16 -3
- package/packages/convex/dist/index.d.ts +56 -0
- package/packages/convex/dist/index.js +133 -0
- package/packages/convex/dist/index.js.map +1 -0
- package/packages/react/README.md +1 -1
- package/packages/react/dist/index.d.ts +156 -0
- package/packages/react/dist/index.js +567 -0
- package/packages/react/dist/index.js.map +1 -0
- package/packages/react-css/README.md +1 -1
- package/packages/react-css/dist/index.cjs +571 -0
- package/packages/react-css/dist/index.cjs.map +1 -0
- package/packages/react-css/{src/seo.css → dist/index.css} +7 -153
- package/packages/react-css/dist/index.css.map +1 -0
- package/packages/react-css/dist/index.d.cts +53 -0
- package/packages/react-css/dist/index.d.ts +53 -0
- package/packages/react-css/dist/index.js +539 -0
- package/packages/react-css/dist/index.js.map +1 -0
- package/packages/shared/README.md +1 -1
- package/packages/shared/dist/index.d.ts +262 -0
- package/packages/shared/dist/index.js +381 -0
- package/packages/shared/dist/index.js.map +1 -0
- package/packages/solidjs/README.md +1 -1
- package/packages/solidjs/dist/index.d.ts +133 -0
- package/packages/solidjs/dist/index.js +416 -0
- package/packages/solidjs/dist/index.js.map +1 -0
- package/packages/solidjs-css/README.md +1 -1
- package/packages/solidjs-css/dist/index.cjs +399 -0
- package/packages/solidjs-css/dist/index.cjs.map +1 -0
- package/packages/solidjs-css/{src/seo.css → dist/index.css} +7 -153
- package/packages/solidjs-css/dist/index.css.map +1 -0
- package/packages/solidjs-css/dist/index.d.cts +53 -0
- package/packages/solidjs-css/dist/index.d.ts +53 -0
- package/packages/solidjs-css/dist/index.js +367 -0
- package/packages/solidjs-css/dist/index.js.map +1 -0
- package/.changeset/config.json +0 -11
- package/.github/CODEOWNERS +0 -1
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -16
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -11
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -10
- package/.github/dependabot.yml +0 -11
- package/.github/workflows/ci.yml +0 -23
- package/.github/workflows/release.yml +0 -29
- package/.nvmrc +0 -1
- package/.project/ACCOUNT.yaml +0 -4
- package/.project/IDEAS.yaml +0 -7
- package/.project/PROJECT.yaml +0 -11
- package/.project/ROADMAP.yaml +0 -15
- package/CODE_OF_CONDUCT.md +0 -16
- package/CONTRIBUTING.md +0 -26
- package/SECURITY.md +0 -15
- package/SUPPORT.md +0 -8
- package/packages/convex/package.json +0 -42
- package/packages/convex/src/functions.ts +0 -5
- package/packages/convex/src/mutations.ts +0 -83
- package/packages/convex/src/queries.ts +0 -57
- package/packages/convex/src/schema.ts +0 -23
- package/packages/convex/tsconfig.json +0 -19
- package/packages/convex/tsup.config.ts +0 -18
- package/packages/react/package.json +0 -49
- package/packages/react/src/components/ArticleJsonLd.tsx +0 -42
- package/packages/react/src/components/BreadcrumbsJsonLd.tsx +0 -24
- package/packages/react/src/components/MetaEditor.tsx +0 -147
- package/packages/react/src/components/SEOHead.tsx +0 -107
- package/packages/react/src/components/SEOPreview.tsx +0 -42
- package/packages/react/src/components/SEOScoreCard.tsx +0 -51
- package/packages/react/src/components/SitemapViewer.tsx +0 -36
- package/packages/react/src/components/index.ts +0 -7
- package/packages/react/src/hooks/index.ts +0 -4
- package/packages/react/src/hooks/useSEO.ts +0 -27
- package/packages/react/src/hooks/useSEOAdmin.ts +0 -42
- package/packages/react/src/hooks/useSEOScore.ts +0 -7
- package/packages/react/src/hooks/useSitemap.ts +0 -8
- package/packages/react/src/index.ts +0 -51
- package/packages/react/src/index.tsx +0 -11
- package/packages/react/src/pages/SEOAdminPage.tsx +0 -101
- package/packages/react/src/pages/SEOAnalyticsPage.tsx +0 -96
- package/packages/react/src/pages/index.ts +0 -2
- package/packages/react/tsconfig.json +0 -19
- package/packages/react/tsup.config.ts +0 -12
- package/packages/react-css/package.json +0 -36
- package/packages/react-css/src/components/ArticleJsonLd.tsx +0 -42
- package/packages/react-css/src/components/BreadcrumbsJsonLd.tsx +0 -24
- package/packages/react-css/src/components/MetaEditor.tsx +0 -147
- package/packages/react-css/src/components/SEOHead.tsx +0 -95
- package/packages/react-css/src/components/SEOPreview.tsx +0 -42
- package/packages/react-css/src/components/SEOScoreCard.tsx +0 -42
- package/packages/react-css/src/components/SitemapViewer.tsx +0 -36
- package/packages/react-css/src/components/index.ts +0 -7
- package/packages/react-css/src/index.ts +0 -9
- package/packages/react-css/src/pages/SEOAdminPage.tsx +0 -88
- package/packages/react-css/src/pages/SEOAnalyticsPage.tsx +0 -82
- package/packages/react-css/src/pages/index.ts +0 -2
- package/packages/react-css/tsup.config.ts +0 -2
- package/packages/shared/package.json +0 -42
- package/packages/shared/src/__tests__/seo.test.ts +0 -70
- package/packages/shared/src/config.ts +0 -297
- package/packages/shared/src/index.ts +0 -207
- package/packages/shared/tsconfig.json +0 -18
- package/packages/shared/tsup.config.ts +0 -11
- package/packages/shared/vitest.config.ts +0 -4
- package/packages/solidjs/package.json +0 -45
- package/packages/solidjs/src/components/ArticleJsonLd.tsx +0 -35
- package/packages/solidjs/src/components/BreadcrumbsJsonLd.tsx +0 -24
- package/packages/solidjs/src/components/MetaEditor.tsx +0 -155
- package/packages/solidjs/src/components/SEOHead.tsx +0 -109
- package/packages/solidjs/src/components/SEOPreview.tsx +0 -42
- package/packages/solidjs/src/components/SEOScoreCard.tsx +0 -57
- package/packages/solidjs/src/components/SitemapViewer.tsx +0 -44
- package/packages/solidjs/src/components/index.ts +0 -7
- package/packages/solidjs/src/index.ts +0 -11
- package/packages/solidjs/src/pages/SEOAdminPage.tsx +0 -104
- package/packages/solidjs/src/pages/SEOAnalyticsPage.tsx +0 -102
- package/packages/solidjs/src/pages/index.ts +0 -2
- package/packages/solidjs/src/primitives/index.ts +0 -4
- package/packages/solidjs/src/primitives/useSEO.ts +0 -27
- package/packages/solidjs/src/primitives/useSEOAdmin.ts +0 -42
- package/packages/solidjs/src/primitives/useSEOScore.ts +0 -7
- package/packages/solidjs/src/primitives/useSitemap.ts +0 -8
- package/packages/solidjs/tsconfig.json +0 -20
- package/packages/solidjs/tsup.config.ts +0 -12
- package/packages/solidjs-css/package.json +0 -35
- package/packages/solidjs-css/src/index.ts +0 -5
- package/packages/solidjs-css/src/primitives/index.ts +0 -1
- package/packages/solidjs-css/tsup.config.ts +0 -2
- package/pnpm-workspace.yaml +0 -2
- package/tsconfig.json +0 -23
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
2
|
-
import { useSEOAdmin } from '@geenius-seo/react'
|
|
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 style={{ padding: '1rem' }}>Loading...</div>
|
|
31
|
-
|
|
32
|
-
return (
|
|
33
|
-
<div className="seo__admin-container">
|
|
34
|
-
<h1 className="seo__admin-title">SEO Admin</h1>
|
|
35
|
-
|
|
36
|
-
<div className="seo__admin-grid">
|
|
37
|
-
<div className="seo__admin-sidebar">
|
|
38
|
-
<div className="seo__admin-sidebar-title">Pages</div>
|
|
39
|
-
<div className="seo__admin-page-list">
|
|
40
|
-
{pages.map((page) => (
|
|
41
|
-
<button
|
|
42
|
-
key={page.path}
|
|
43
|
-
onClick={() => {
|
|
44
|
-
setSelectedPath(page.path)
|
|
45
|
-
setEditingMeta(page.meta)
|
|
46
|
-
}}
|
|
47
|
-
className={`seo__admin-page-button ${
|
|
48
|
-
selectedPath === page.path ? 'seo__admin-page-button--active' : ''
|
|
49
|
-
}`}
|
|
50
|
-
>
|
|
51
|
-
{page.path || '/'}
|
|
52
|
-
</button>
|
|
53
|
-
))}
|
|
54
|
-
</div>
|
|
55
|
-
</div>
|
|
56
|
-
|
|
57
|
-
{currentPage && editingMeta && (
|
|
58
|
-
<div className="seo__admin-editor">
|
|
59
|
-
<div>
|
|
60
|
-
<h2 style={{ fontWeight: 600, marginBottom: '1rem' }}>Edit Metadata</h2>
|
|
61
|
-
<MetaEditor meta={editingMeta} onChange={setEditingMeta} />
|
|
62
|
-
</div>
|
|
63
|
-
|
|
64
|
-
<div className="seo__space-y">
|
|
65
|
-
<div>
|
|
66
|
-
<h2 style={{ fontWeight: 600, marginBottom: '1rem' }}>Preview</h2>
|
|
67
|
-
<SEOPreview meta={editingMeta} />
|
|
68
|
-
</div>
|
|
69
|
-
|
|
70
|
-
<SEOScoreCard meta={editingMeta} />
|
|
71
|
-
</div>
|
|
72
|
-
</div>
|
|
73
|
-
)}
|
|
74
|
-
</div>
|
|
75
|
-
|
|
76
|
-
{currentPage && editingMeta && (
|
|
77
|
-
<div className="seo__admin-actions">
|
|
78
|
-
<button onClick={handleSave} className="seo__button seo__button--success">
|
|
79
|
-
Save
|
|
80
|
-
</button>
|
|
81
|
-
<button onClick={handleDelete} className="seo__button seo__button--danger">
|
|
82
|
-
Delete
|
|
83
|
-
</button>
|
|
84
|
-
</div>
|
|
85
|
-
)}
|
|
86
|
-
</div>
|
|
87
|
-
)
|
|
88
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
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
|
-
setIsLoading(false)
|
|
17
|
-
}, [])
|
|
18
|
-
|
|
19
|
-
const totalViews = topPages.reduce((sum, p) => sum + p.views, 0)
|
|
20
|
-
const avgTimeOnPage =
|
|
21
|
-
topPages.length > 0
|
|
22
|
-
? topPages.reduce((sum, p) => sum + p.avgTimeOnPage, 0) / topPages.length
|
|
23
|
-
: 0
|
|
24
|
-
const avgBounceRate =
|
|
25
|
-
topPages.length > 0
|
|
26
|
-
? (topPages.reduce((sum, p) => sum + p.bounceRate, 0) / topPages.length) * 100
|
|
27
|
-
: 0
|
|
28
|
-
|
|
29
|
-
if (isLoading) return <div style={{ padding: '1rem' }}>Loading...</div>
|
|
30
|
-
|
|
31
|
-
return (
|
|
32
|
-
<div className="seo__analytics-container">
|
|
33
|
-
<h1 className="seo__analytics-title">SEO Analytics</h1>
|
|
34
|
-
|
|
35
|
-
<div className="seo__analytics-table-wrapper">
|
|
36
|
-
<table className="seo__sitemap-table">
|
|
37
|
-
<thead className="seo__sitemap-table-head">
|
|
38
|
-
<tr>
|
|
39
|
-
<th className="seo__sitemap-table-header">Page</th>
|
|
40
|
-
<th className="seo__sitemap-table-header">Views</th>
|
|
41
|
-
<th className="seo__sitemap-table-header">Avg Time (s)</th>
|
|
42
|
-
<th className="seo__sitemap-table-header">Bounce Rate</th>
|
|
43
|
-
</tr>
|
|
44
|
-
</thead>
|
|
45
|
-
<tbody>
|
|
46
|
-
{topPages.map((page, i) => (
|
|
47
|
-
<tr key={i} className="seo__sitemap-row">
|
|
48
|
-
<td className="seo__sitemap-cell">{page.path}</td>
|
|
49
|
-
<td className="seo__sitemap-cell">{page.views.toLocaleString()}</td>
|
|
50
|
-
<td className="seo__sitemap-cell">{page.avgTimeOnPage.toFixed(1)}</td>
|
|
51
|
-
<td className="seo__sitemap-cell">{(page.bounceRate * 100).toFixed(1)}%</td>
|
|
52
|
-
</tr>
|
|
53
|
-
))}
|
|
54
|
-
</tbody>
|
|
55
|
-
</table>
|
|
56
|
-
</div>
|
|
57
|
-
|
|
58
|
-
{topPages.length === 0 && (
|
|
59
|
-
<p style={{ textAlign: 'center', color: 'var(--seo-text-muted)', padding: '2rem' }}>
|
|
60
|
-
No analytics data available
|
|
61
|
-
</p>
|
|
62
|
-
)}
|
|
63
|
-
|
|
64
|
-
<div className="seo__analytics-summary">
|
|
65
|
-
<div className="seo__analytics-card">
|
|
66
|
-
<div className="seo__analytics-card-label">Total Views</div>
|
|
67
|
-
<div className="seo__analytics-card-value">{totalViews.toLocaleString()}</div>
|
|
68
|
-
</div>
|
|
69
|
-
|
|
70
|
-
<div className="seo__analytics-card">
|
|
71
|
-
<div className="seo__analytics-card-label">Average Time on Page</div>
|
|
72
|
-
<div className="seo__analytics-card-value">{avgTimeOnPage.toFixed(1)}s</div>
|
|
73
|
-
</div>
|
|
74
|
-
|
|
75
|
-
<div className="seo__analytics-card">
|
|
76
|
-
<div className="seo__analytics-card-label">Average Bounce Rate</div>
|
|
77
|
-
<div className="seo__analytics-card-value">{avgBounceRate.toFixed(1)}%</div>
|
|
78
|
-
</div>
|
|
79
|
-
</div>
|
|
80
|
-
</div>
|
|
81
|
-
)
|
|
82
|
-
}
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@geenius-seo/shared",
|
|
3
|
-
"version": "0.1.0",
|
|
4
|
-
"private": false,
|
|
5
|
-
"type": "module",
|
|
6
|
-
"description": "Geenius Seo \u2014 Shared types & Convex schema",
|
|
7
|
-
"author": "Antigravity HQ",
|
|
8
|
-
"license": "MIT",
|
|
9
|
-
"publishConfig": {
|
|
10
|
-
"access": "public"
|
|
11
|
-
},
|
|
12
|
-
"main": "./dist/index.js",
|
|
13
|
-
"module": "./dist/index.js",
|
|
14
|
-
"types": "./dist/index.d.ts",
|
|
15
|
-
"exports": {
|
|
16
|
-
".": {
|
|
17
|
-
"types": "./dist/index.d.ts",
|
|
18
|
-
"import": "./dist/index.js"
|
|
19
|
-
}
|
|
20
|
-
},
|
|
21
|
-
"files": [
|
|
22
|
-
"dist",
|
|
23
|
-
"src"
|
|
24
|
-
],
|
|
25
|
-
"scripts": {
|
|
26
|
-
"build": "tsup",
|
|
27
|
-
"clean": "rm -rf dist",
|
|
28
|
-
"type-check": "tsc --noEmit",
|
|
29
|
-
"prepublishOnly": "pnpm clean && pnpm build",
|
|
30
|
-
"test": "vitest run",
|
|
31
|
-
"test:watch": "vitest",
|
|
32
|
-
"test:coverage": "vitest run --coverage"
|
|
33
|
-
},
|
|
34
|
-
"devDependencies": {
|
|
35
|
-
"tsup": "^8.5.1",
|
|
36
|
-
"typescript": "~6.0.2",
|
|
37
|
-
"vitest": "^4.0.0"
|
|
38
|
-
},
|
|
39
|
-
"engines": {
|
|
40
|
-
"node": ">=20.0.0"
|
|
41
|
-
}
|
|
42
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { buildTitle, truncateDescription, articleSchema } from '../index'
|
|
3
|
-
|
|
4
|
-
describe('buildTitle', () => {
|
|
5
|
-
it('joins page title and site name with separator', () => {
|
|
6
|
-
expect(buildTitle('Home', 'Geenius')).toBe('Home | Geenius')
|
|
7
|
-
})
|
|
8
|
-
|
|
9
|
-
it('allows custom separator', () => {
|
|
10
|
-
expect(buildTitle('Blog', 'Geenius', ' — ')).toBe('Blog — Geenius')
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
it('handles empty page title', () => {
|
|
14
|
-
expect(buildTitle('', 'Geenius')).toBe(' | Geenius')
|
|
15
|
-
})
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
describe('truncateDescription', () => {
|
|
19
|
-
it('returns short descriptions unchanged', () => {
|
|
20
|
-
const short = 'A brief description.'
|
|
21
|
-
expect(truncateDescription(short)).toBe(short)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it('truncates to 160 chars by default', () => {
|
|
25
|
-
const long = 'x'.repeat(200)
|
|
26
|
-
const result = truncateDescription(long)
|
|
27
|
-
expect(result.length).toBe(160)
|
|
28
|
-
expect(result.endsWith('...')).toBe(true)
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
it('respects custom max length', () => {
|
|
32
|
-
const text = 'x'.repeat(100)
|
|
33
|
-
const result = truncateDescription(text, 50)
|
|
34
|
-
expect(result.length).toBe(50)
|
|
35
|
-
expect(result.endsWith('...')).toBe(true)
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('returns exact-length descriptions unchanged', () => {
|
|
39
|
-
const exact = 'x'.repeat(160)
|
|
40
|
-
expect(truncateDescription(exact)).toBe(exact)
|
|
41
|
-
})
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
describe('articleSchema', () => {
|
|
45
|
-
it('generates valid JSON-LD structure', () => {
|
|
46
|
-
const schema = articleSchema({
|
|
47
|
-
title: 'Test Article',
|
|
48
|
-
description: 'A test article',
|
|
49
|
-
author: 'Author',
|
|
50
|
-
datePublished: '2024-01-01',
|
|
51
|
-
url: 'https://example.com/test',
|
|
52
|
-
})
|
|
53
|
-
expect(schema['@context']).toBe('https://schema.org')
|
|
54
|
-
expect(schema['@type']).toBe('Article')
|
|
55
|
-
expect(schema.title).toBe('Test Article')
|
|
56
|
-
expect(schema.author).toBe('Author')
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('includes optional image when provided', () => {
|
|
60
|
-
const schema = articleSchema({
|
|
61
|
-
title: 'Test',
|
|
62
|
-
description: 'Desc',
|
|
63
|
-
author: 'Author',
|
|
64
|
-
datePublished: '2024-01-01',
|
|
65
|
-
url: 'https://example.com',
|
|
66
|
-
image: 'https://example.com/img.jpg',
|
|
67
|
-
})
|
|
68
|
-
expect(schema.image).toBe('https://example.com/img.jpg')
|
|
69
|
-
})
|
|
70
|
-
})
|
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SEO configuration and utilities
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { SEOConfig, SEOMeta, OGMeta, TwitterMeta } from './index'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* SEO configuration builder for type-safe setup
|
|
9
|
-
*
|
|
10
|
-
* @example
|
|
11
|
-
* ```ts
|
|
12
|
-
* const seoConfig = createSEOConfig()
|
|
13
|
-
* .withSiteName('My Site')
|
|
14
|
-
* .withSiteUrl('https://example.com')
|
|
15
|
-
* .withDefaultImage('https://example.com/og-image.jpg')
|
|
16
|
-
* .withTwitterHandle('@mysite')
|
|
17
|
-
* ```
|
|
18
|
-
*/
|
|
19
|
-
export class SEOConfigBuilder {
|
|
20
|
-
private config: SEOConfig = {
|
|
21
|
-
siteName: '',
|
|
22
|
-
siteUrl: '',
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Sets the site name
|
|
27
|
-
*/
|
|
28
|
-
withSiteName(name: string): this {
|
|
29
|
-
this.config.siteName = name
|
|
30
|
-
return this
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Sets the site URL
|
|
35
|
-
*/
|
|
36
|
-
withSiteUrl(url: string): this {
|
|
37
|
-
this.config.siteUrl = url
|
|
38
|
-
return this
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Sets default OG image
|
|
43
|
-
*/
|
|
44
|
-
withDefaultImage(url: string): this {
|
|
45
|
-
this.config.defaultImage = url
|
|
46
|
-
return this
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Sets Twitter handle
|
|
51
|
-
*/
|
|
52
|
-
withTwitterHandle(handle: string): this {
|
|
53
|
-
this.config.twitterHandle = handle
|
|
54
|
-
return this
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Sets default locale
|
|
59
|
-
*/
|
|
60
|
-
withLocale(locale: string): this {
|
|
61
|
-
this.config.locale = locale
|
|
62
|
-
return this
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Sets Google Analytics ID
|
|
67
|
-
*/
|
|
68
|
-
withGoogleAnalyticsId(id: string): this {
|
|
69
|
-
this.config.googleAnalyticsId = id
|
|
70
|
-
return this
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Sets Google Tag Manager ID
|
|
75
|
-
*/
|
|
76
|
-
withGoogleTagManagerId(id: string): this {
|
|
77
|
-
this.config.googleTagManagerId = id
|
|
78
|
-
return this
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Builds the configuration
|
|
83
|
-
*/
|
|
84
|
-
build(): SEOConfig {
|
|
85
|
-
if (!this.config.siteName || !this.config.siteUrl) {
|
|
86
|
-
throw new Error('SEO config requires siteName and siteUrl')
|
|
87
|
-
}
|
|
88
|
-
return this.config
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Creates a new SEO configuration builder
|
|
94
|
-
*/
|
|
95
|
-
export function createSEOConfig(): SEOConfigBuilder {
|
|
96
|
-
return new SEOConfigBuilder()
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Meta tags builder for individual pages
|
|
101
|
-
*
|
|
102
|
-
* @example
|
|
103
|
-
* ```ts
|
|
104
|
-
* const meta = createPageMeta({
|
|
105
|
-
* title: 'Home',
|
|
106
|
-
* description: 'Welcome to my site'
|
|
107
|
-
* })
|
|
108
|
-
* .withKeywords(['web', 'development'])
|
|
109
|
-
* .withImage('https://example.com/image.jpg')
|
|
110
|
-
* ```
|
|
111
|
-
*/
|
|
112
|
-
export class PageMetaBuilder {
|
|
113
|
-
private meta: Partial<SEOMeta> = {
|
|
114
|
-
og: {} as OGMeta,
|
|
115
|
-
twitter: {} as TwitterMeta,
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
constructor(private config: SEOConfig, title: string, description: string) {
|
|
119
|
-
this.meta.title = title
|
|
120
|
-
this.meta.description = description
|
|
121
|
-
this.meta.og = {
|
|
122
|
-
title,
|
|
123
|
-
description,
|
|
124
|
-
type: 'website',
|
|
125
|
-
url: config.siteUrl,
|
|
126
|
-
siteName: config.siteName,
|
|
127
|
-
image: config.defaultImage,
|
|
128
|
-
}
|
|
129
|
-
this.meta.twitter = {
|
|
130
|
-
card: 'summary_large_image',
|
|
131
|
-
title,
|
|
132
|
-
description,
|
|
133
|
-
image: config.defaultImage,
|
|
134
|
-
creator: config.twitterHandle,
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Adds keywords
|
|
140
|
-
*/
|
|
141
|
-
withKeywords(keywords: string[]): this {
|
|
142
|
-
this.meta.keywords = keywords
|
|
143
|
-
return this
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Sets canonical URL
|
|
148
|
-
*/
|
|
149
|
-
withCanonical(url: string): this {
|
|
150
|
-
this.meta.canonical = url
|
|
151
|
-
return this
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Sets OG image
|
|
156
|
-
*/
|
|
157
|
-
withImage(url: string, alt?: string): this {
|
|
158
|
-
if (this.meta.og) {
|
|
159
|
-
this.meta.og.image = url
|
|
160
|
-
if (alt) this.meta.og.imageAlt = alt
|
|
161
|
-
}
|
|
162
|
-
if (this.meta.twitter) {
|
|
163
|
-
this.meta.twitter.image = url
|
|
164
|
-
}
|
|
165
|
-
return this
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Sets OG type
|
|
170
|
-
*/
|
|
171
|
-
withType(type: 'website' | 'article' | 'product'): this {
|
|
172
|
-
if (this.meta.og) {
|
|
173
|
-
this.meta.og.type = type
|
|
174
|
-
}
|
|
175
|
-
return this
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Adds JSON-LD schema
|
|
180
|
-
*/
|
|
181
|
-
withJsonLd(schema: Record<string, unknown>): this {
|
|
182
|
-
if (!this.meta.jsonLd) {
|
|
183
|
-
this.meta.jsonLd = []
|
|
184
|
-
}
|
|
185
|
-
this.meta.jsonLd.push(schema)
|
|
186
|
-
return this
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Sets robots meta
|
|
191
|
-
*/
|
|
192
|
-
withRobots(robots: string): this {
|
|
193
|
-
this.meta.robots = robots
|
|
194
|
-
return this
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Adds alternate language links
|
|
199
|
-
*/
|
|
200
|
-
withAlternates(alternates: Record<string, string>): this {
|
|
201
|
-
this.meta.alternates = alternates
|
|
202
|
-
return this
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Builds the meta tags
|
|
207
|
-
*/
|
|
208
|
-
build(): SEOMeta {
|
|
209
|
-
return {
|
|
210
|
-
title: this.meta.title || '',
|
|
211
|
-
description: this.meta.description || '',
|
|
212
|
-
keywords: this.meta.keywords || [],
|
|
213
|
-
canonical: this.meta.canonical,
|
|
214
|
-
og: this.meta.og || ({ type: 'website', title: '', description: '', url: '' } as OGMeta),
|
|
215
|
-
twitter: this.meta.twitter || ({ card: 'summary', title: '', description: '' } as TwitterMeta),
|
|
216
|
-
robots: this.meta.robots,
|
|
217
|
-
alternates: this.meta.alternates,
|
|
218
|
-
jsonLd: this.meta.jsonLd,
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Creates page-specific meta tags
|
|
225
|
-
*/
|
|
226
|
-
export function createPageMeta(config: SEOConfig, title: string, description: string): PageMetaBuilder {
|
|
227
|
-
return new PageMetaBuilder(config, title, description)
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Validates SEO meta configuration
|
|
232
|
-
*/
|
|
233
|
-
export function validateSEOMeta(meta: SEOMeta): { valid: boolean; errors: string[] } {
|
|
234
|
-
const errors: string[] = []
|
|
235
|
-
|
|
236
|
-
if (!meta.title || meta.title.length === 0) {
|
|
237
|
-
errors.push('Title is required')
|
|
238
|
-
}
|
|
239
|
-
if (meta.title && meta.title.length > 60) {
|
|
240
|
-
errors.push('Title should be under 60 characters')
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
if (!meta.description || meta.description.length === 0) {
|
|
244
|
-
errors.push('Description is required')
|
|
245
|
-
}
|
|
246
|
-
if (meta.description && meta.description.length > 160) {
|
|
247
|
-
errors.push('Description should be under 160 characters')
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
if (!meta.og?.image && !meta.twitter?.image) {
|
|
251
|
-
errors.push('At least one image (OG or Twitter) is recommended')
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (!meta.keywords || meta.keywords.length === 0) {
|
|
255
|
-
errors.push('At least one keyword is recommended')
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
return {
|
|
259
|
-
valid: errors.length === 0,
|
|
260
|
-
errors,
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Presets for common SEO configurations
|
|
266
|
-
*/
|
|
267
|
-
export const seoPresets = {
|
|
268
|
-
/**
|
|
269
|
-
* Blog article preset
|
|
270
|
-
*/
|
|
271
|
-
article: (config: SEOConfig): Partial<SEOMeta> => ({
|
|
272
|
-
og: { type: 'article', ...config },
|
|
273
|
-
robots: 'index, follow',
|
|
274
|
-
}),
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* E-commerce product preset
|
|
278
|
-
*/
|
|
279
|
-
product: (config: SEOConfig): Partial<SEOMeta> => ({
|
|
280
|
-
og: { type: 'product', ...config },
|
|
281
|
-
robots: 'index, follow',
|
|
282
|
-
}),
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Service/company page preset
|
|
286
|
-
*/
|
|
287
|
-
business: (config: SEOConfig): Partial<SEOMeta> => ({
|
|
288
|
-
robots: 'index, follow',
|
|
289
|
-
}),
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* Private/unlisted page preset
|
|
293
|
-
*/
|
|
294
|
-
private: (): Partial<SEOMeta> => ({
|
|
295
|
-
robots: 'noindex, nofollow',
|
|
296
|
-
}),
|
|
297
|
-
}
|