@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,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,11 @@
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
+ })
@@ -0,0 +1,4 @@
1
+ import { defineConfig } from 'vitest/config'
2
+ export default defineConfig({
3
+ test: { globals: true, environment: 'node' },
4
+ })
@@ -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
+ }