@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,70 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,297 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// ─── SEO Metadata Interfaces ─────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface OGMeta {
|
|
4
|
+
title: string
|
|
5
|
+
description: string
|
|
6
|
+
image?: string
|
|
7
|
+
imageAlt?: string
|
|
8
|
+
type: 'website' | 'article' | 'product'
|
|
9
|
+
url: string
|
|
10
|
+
siteName?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TwitterMeta {
|
|
14
|
+
card: 'summary' | 'summary_large_image'
|
|
15
|
+
title: string
|
|
16
|
+
description: string
|
|
17
|
+
image?: string
|
|
18
|
+
creator?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SEOMeta {
|
|
22
|
+
title: string
|
|
23
|
+
description: string
|
|
24
|
+
keywords: string[]
|
|
25
|
+
canonical?: string
|
|
26
|
+
og: OGMeta
|
|
27
|
+
twitter: TwitterMeta
|
|
28
|
+
robots?: string
|
|
29
|
+
alternates?: Record<string, string>
|
|
30
|
+
jsonLd?: Record<string, unknown>[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SitemapEntry {
|
|
34
|
+
url: string
|
|
35
|
+
lastmod?: string
|
|
36
|
+
changefreq?: string
|
|
37
|
+
priority?: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RobotsTxt {
|
|
41
|
+
allow: string[]
|
|
42
|
+
disallow: string[]
|
|
43
|
+
sitemap?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SEOConfig {
|
|
47
|
+
siteName: string
|
|
48
|
+
siteUrl: string
|
|
49
|
+
defaultImage?: string
|
|
50
|
+
twitterHandle?: string
|
|
51
|
+
locale?: string
|
|
52
|
+
googleAnalyticsId?: string
|
|
53
|
+
googleTagManagerId?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Utility Functions ──────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export function buildTitle(pageTitle: string, siteName: string, sep = ' | '): string {
|
|
59
|
+
return `${pageTitle}${sep}${siteName}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function truncateDescription(desc: string, max = 160): string {
|
|
63
|
+
if (desc.length <= max) return desc
|
|
64
|
+
return desc.slice(0, max - 3) + '...'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── JSON-LD Schema Builders ────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
export function articleSchema(data: {
|
|
70
|
+
title: string
|
|
71
|
+
description: string
|
|
72
|
+
author: string
|
|
73
|
+
datePublished: string
|
|
74
|
+
url: string
|
|
75
|
+
image?: string
|
|
76
|
+
}): Record<string, unknown> {
|
|
77
|
+
return {
|
|
78
|
+
'@context': 'https://schema.org',
|
|
79
|
+
'@type': 'Article',
|
|
80
|
+
...data,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function productSchema(data: {
|
|
85
|
+
name: string
|
|
86
|
+
description: string
|
|
87
|
+
price: number
|
|
88
|
+
currency: string
|
|
89
|
+
url: string
|
|
90
|
+
image?: string
|
|
91
|
+
}): Record<string, unknown> {
|
|
92
|
+
return {
|
|
93
|
+
'@context': 'https://schema.org',
|
|
94
|
+
'@type': 'Product',
|
|
95
|
+
name: data.name,
|
|
96
|
+
description: data.description,
|
|
97
|
+
url: data.url,
|
|
98
|
+
image: data.image,
|
|
99
|
+
offers: {
|
|
100
|
+
'@type': 'Offer',
|
|
101
|
+
price: data.price,
|
|
102
|
+
priceCurrency: data.currency,
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function orgSchema(data: {
|
|
108
|
+
name: string
|
|
109
|
+
url: string
|
|
110
|
+
logo?: string
|
|
111
|
+
sameAs?: string[]
|
|
112
|
+
}): Record<string, unknown> {
|
|
113
|
+
return {
|
|
114
|
+
'@context': 'https://schema.org',
|
|
115
|
+
'@type': 'Organization',
|
|
116
|
+
...data,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function breadcrumbSchema(items: {
|
|
121
|
+
name: string
|
|
122
|
+
url: string
|
|
123
|
+
}[]): Record<string, unknown> {
|
|
124
|
+
return {
|
|
125
|
+
'@context': 'https://schema.org',
|
|
126
|
+
'@type': 'BreadcrumbList',
|
|
127
|
+
itemListElement: items.map((item, i) => ({
|
|
128
|
+
'@type': 'ListItem',
|
|
129
|
+
position: i + 1,
|
|
130
|
+
name: item.name,
|
|
131
|
+
item: item.url,
|
|
132
|
+
})),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function faqSchema(items: {
|
|
137
|
+
question: string
|
|
138
|
+
answer: string
|
|
139
|
+
}[]): Record<string, unknown> {
|
|
140
|
+
return {
|
|
141
|
+
'@context': 'https://schema.org',
|
|
142
|
+
'@type': 'FAQPage',
|
|
143
|
+
mainEntity: items.map((q) => ({
|
|
144
|
+
'@type': 'Question',
|
|
145
|
+
name: q.question,
|
|
146
|
+
acceptedAnswer: {
|
|
147
|
+
'@type': 'Answer',
|
|
148
|
+
text: q.answer,
|
|
149
|
+
},
|
|
150
|
+
})),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function buildCanonical(path: string, baseUrl: string): string {
|
|
155
|
+
return `${baseUrl.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function buildAlternates(
|
|
159
|
+
locales: string[],
|
|
160
|
+
baseUrl: string,
|
|
161
|
+
path: string,
|
|
162
|
+
): Record<string, string> {
|
|
163
|
+
return Object.fromEntries(locales.map((l) => [l, `${baseUrl}/${l}${path}`]))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function generateRobotsTxt(config: RobotsTxt): string {
|
|
167
|
+
const lines = ['User-agent: *']
|
|
168
|
+
config.allow.forEach((p) => lines.push(`Allow: ${p}`))
|
|
169
|
+
config.disallow.forEach((p) => lines.push(`Disallow: ${p}`))
|
|
170
|
+
if (config.sitemap) lines.push(`Sitemap: ${config.sitemap}`)
|
|
171
|
+
return lines.join('\n')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function generateSitemapXml(entries: SitemapEntry[]): string {
|
|
175
|
+
const urls = entries
|
|
176
|
+
.map(
|
|
177
|
+
(e) =>
|
|
178
|
+
` <url>\n <loc>${e.url}</loc>${e.lastmod ? `\n <lastmod>${e.lastmod}</lastmod>` : ''}${e.changefreq ? `\n <changefreq>${e.changefreq}</changefreq>` : ''}${e.priority !== undefined ? `\n <priority>${e.priority}</priority>` : ''}\n </url>`,
|
|
179
|
+
)
|
|
180
|
+
.join('\n')
|
|
181
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function estimateReadingTime(content: string): number {
|
|
185
|
+
return Math.ceil(content.split(/\s+/).length / 200)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function calcSEOScore(meta: SEOMeta): {
|
|
189
|
+
score: number
|
|
190
|
+
issues: string[]
|
|
191
|
+
} {
|
|
192
|
+
const issues: string[] = []
|
|
193
|
+
|
|
194
|
+
if (!meta.title || meta.title.length < 10) issues.push('Title too short (< 10 chars)')
|
|
195
|
+
if (meta.title && meta.title.length > 60) issues.push('Title too long (> 60 chars)')
|
|
196
|
+
if (!meta.description || meta.description.length < 50)
|
|
197
|
+
issues.push('Description too short (< 50 chars)')
|
|
198
|
+
if (meta.description && meta.description.length > 160)
|
|
199
|
+
issues.push('Description too long (> 160 chars)')
|
|
200
|
+
if (!meta.keywords || meta.keywords.length === 0) issues.push('No keywords specified')
|
|
201
|
+
if (!meta.canonical) issues.push('No canonical URL')
|
|
202
|
+
if (!meta.og.image) issues.push('No OG image')
|
|
203
|
+
if (!meta.twitter.image) issues.push('No Twitter image')
|
|
204
|
+
|
|
205
|
+
const score = Math.max(0, 100 - issues.length * 12)
|
|
206
|
+
return { score, issues }
|
|
207
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "dist",
|
|
5
|
+
"rootDir": "src",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"isolatedModules": true,
|
|
11
|
+
"target": "ES2022",
|
|
12
|
+
"module": "ESNext",
|
|
13
|
+
"moduleResolution": "bundler"
|
|
14
|
+
},
|
|
15
|
+
"include": [
|
|
16
|
+
"src"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# ✦ @geenius-seo/solidjs\n\n> Geenius Seo — SolidJS components & primitives\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/solidjs\n```\n\n## Usage\n\n```typescript\nimport { init } from '@geenius-seo/solidjs';\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,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@geenius-seo/solidjs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Geenius Seo — SolidJS components & primitives",
|
|
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
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@geenius-seo/shared": "workspace:*"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"solid-js": "^1.9.0",
|
|
36
|
+
"tsup": "^8.5.1",
|
|
37
|
+
"typescript": "~6.0.2"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"solid-js": "^1.8.0 || ^1.9.0"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=20.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createEffect } from 'solid-js'
|
|
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(props: ArticleJsonLdProps) {
|
|
14
|
+
createEffect(() => {
|
|
15
|
+
const script = document.createElement('script')
|
|
16
|
+
script.type = 'application/ld+json'
|
|
17
|
+
script.textContent = JSON.stringify(
|
|
18
|
+
articleSchema({
|
|
19
|
+
title: props.title,
|
|
20
|
+
description: props.description,
|
|
21
|
+
author: props.author,
|
|
22
|
+
datePublished: props.datePublished,
|
|
23
|
+
url: props.url,
|
|
24
|
+
image: props.image,
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
document.head.appendChild(script)
|
|
28
|
+
|
|
29
|
+
return () => {
|
|
30
|
+
document.head.removeChild(script)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
return null
|
|
35
|
+
}
|