@growth-labs/seo 0.5.0 → 0.5.1
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/README.md +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +56 -37
- package/dist/index.js.map +1 -1
- package/dist/options.d.ts +140 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +18 -0
- package/dist/options.js.map +1 -1
- package/dist/routes/sitemap-index.d.ts.map +1 -1
- package/dist/routes/sitemap-index.js +45 -32
- package/dist/routes/sitemap-index.js.map +1 -1
- package/dist/utils/sitemap.d.ts.map +1 -1
- package/dist/utils/sitemap.js +1 -1
- package/dist/utils/sitemap.js.map +1 -1
- package/dist/utils/validation.d.ts +5 -0
- package/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +78 -4
- package/dist/utils/validation.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +57 -37
- package/src/options.ts +21 -0
- package/src/routes/sitemap-index.ts +48 -35
- package/src/utils/sitemap.ts +4 -2
- package/src/utils/validation.ts +95 -4
package/src/utils/sitemap.ts
CHANGED
|
@@ -105,9 +105,11 @@ ${entries}
|
|
|
105
105
|
|
|
106
106
|
export function generateVideoSitemap(items: ContentItem[]): string {
|
|
107
107
|
const entries = items
|
|
108
|
-
.filter((item)
|
|
108
|
+
.filter((item): item is ContentItem & { video: NonNullable<ContentItem['video']> } =>
|
|
109
|
+
Boolean(item.video && (item.video.contentUrl || item.video.embedUrl)),
|
|
110
|
+
)
|
|
109
111
|
.map((item) => {
|
|
110
|
-
const v = item.video
|
|
112
|
+
const v = item.video
|
|
111
113
|
const contentUrlTag = v.contentUrl
|
|
112
114
|
? `\n <video:content_loc>${escapeXml(v.contentUrl)}</video:content_loc>`
|
|
113
115
|
: ''
|
package/src/utils/validation.ts
CHANGED
|
@@ -7,6 +7,11 @@ export interface PageValidationOptions {
|
|
|
7
7
|
titleMaxLength: number
|
|
8
8
|
descriptionMaxLength: number
|
|
9
9
|
heroMinWidth?: number
|
|
10
|
+
pagePath?: string
|
|
11
|
+
requireH1?: boolean
|
|
12
|
+
requireHeroImage?: boolean
|
|
13
|
+
requireArticleSchema?: boolean
|
|
14
|
+
requireMaxImagePreviewLarge?: boolean
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
/**
|
|
@@ -139,7 +144,7 @@ export function validatePage(html: string, options: PageValidationOptions): Vali
|
|
|
139
144
|
html.match(/<meta\s[^>]*property=["']og:image["'][^>]*>/i) ??
|
|
140
145
|
html.match(/<meta\s[^>]*property=og:image[^>]*>/i)
|
|
141
146
|
if (!ogImageMatch) {
|
|
142
|
-
warnings
|
|
147
|
+
pushIssue(options.requireHeroImage, errors, warnings, 'Missing hero image og:image meta tag')
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
// og:image:width check against heroMinWidth
|
|
@@ -154,18 +159,27 @@ export function validatePage(html: string, options: PageValidationOptions): Vali
|
|
|
154
159
|
if (widthContentMatch) {
|
|
155
160
|
const width = Number(widthContentMatch[1])
|
|
156
161
|
if (!Number.isNaN(width) && width < options.heroMinWidth) {
|
|
157
|
-
|
|
162
|
+
pushIssue(
|
|
163
|
+
options.requireHeroImage,
|
|
164
|
+
errors,
|
|
165
|
+
warnings,
|
|
166
|
+
`Hero image width ${width}px is below minimum ${options.heroMinWidth}px`,
|
|
167
|
+
)
|
|
158
168
|
}
|
|
169
|
+
} else if (options.requireHeroImage) {
|
|
170
|
+
errors.push(`Hero image width is missing; minimum is ${options.heroMinWidth}px`)
|
|
159
171
|
}
|
|
172
|
+
} else if (options.requireHeroImage && ogImageMatch) {
|
|
173
|
+
errors.push(`Hero image width is missing; minimum is ${options.heroMinWidth}px`)
|
|
160
174
|
}
|
|
161
175
|
}
|
|
162
176
|
|
|
163
177
|
// H1 checks
|
|
164
178
|
const h1Matches = html.match(/<h1[\s>]/gi) ?? []
|
|
165
179
|
if (h1Matches.length === 0) {
|
|
166
|
-
warnings
|
|
180
|
+
pushIssue(options.requireH1, errors, warnings, 'Missing H1 tag')
|
|
167
181
|
} else if (h1Matches.length > 1) {
|
|
168
|
-
warnings
|
|
182
|
+
pushIssue(options.requireH1, errors, warnings, `Multiple H1 tags found (${h1Matches.length})`)
|
|
169
183
|
}
|
|
170
184
|
|
|
171
185
|
// JSON-LD presence check
|
|
@@ -174,9 +188,86 @@ export function validatePage(html: string, options: PageValidationOptions): Vali
|
|
|
174
188
|
warnings.push('Missing JSON-LD structured data')
|
|
175
189
|
}
|
|
176
190
|
|
|
191
|
+
if (
|
|
192
|
+
options.requireArticleSchema &&
|
|
193
|
+
isLikelyArticlePath(options.pagePath) &&
|
|
194
|
+
!hasArticleJsonLd(html)
|
|
195
|
+
) {
|
|
196
|
+
errors.push('Missing valid Article JSON-LD for article route')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (options.requireMaxImagePreviewLarge && !hasMaxImagePreviewLarge(html)) {
|
|
200
|
+
errors.push('Missing robots max-image-preview:large directive')
|
|
201
|
+
}
|
|
202
|
+
|
|
177
203
|
return { errors, warnings }
|
|
178
204
|
}
|
|
179
205
|
|
|
206
|
+
function pushIssue(
|
|
207
|
+
asError: boolean | undefined,
|
|
208
|
+
errors: string[],
|
|
209
|
+
warnings: string[],
|
|
210
|
+
message: string,
|
|
211
|
+
) {
|
|
212
|
+
if (asError) errors.push(message)
|
|
213
|
+
else warnings.push(message)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isLikelyArticlePath(pagePath: string | undefined): boolean {
|
|
217
|
+
if (!pagePath) return false
|
|
218
|
+
return /\/(article|articles|news|story|stories)\//i.test(pagePath)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function hasArticleJsonLd(html: string): boolean {
|
|
222
|
+
for (const rawJson of extractJsonLdBodies(html)) {
|
|
223
|
+
try {
|
|
224
|
+
const parsed = JSON.parse(rawJson) as unknown
|
|
225
|
+
if (hasArticleType(parsed)) return true
|
|
226
|
+
} catch {}
|
|
227
|
+
}
|
|
228
|
+
return false
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function extractJsonLdBodies(html: string): string[] {
|
|
232
|
+
const bodies: string[] = []
|
|
233
|
+
const pattern = /<script\s[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi
|
|
234
|
+
for (const match of html.matchAll(pattern)) {
|
|
235
|
+
if (match[1]) bodies.push(match[1].trim())
|
|
236
|
+
}
|
|
237
|
+
return bodies
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function hasArticleType(value: unknown): boolean {
|
|
241
|
+
if (Array.isArray(value)) return value.some(hasArticleType)
|
|
242
|
+
if (!isRecord(value)) return false
|
|
243
|
+
|
|
244
|
+
const type = value['@type']
|
|
245
|
+
if (type === 'Article' || type === 'NewsArticle' || type === 'BlogPosting') return true
|
|
246
|
+
if (
|
|
247
|
+
Array.isArray(type) &&
|
|
248
|
+
type.some((item) => item === 'Article' || item === 'NewsArticle' || item === 'BlogPosting')
|
|
249
|
+
) {
|
|
250
|
+
return true
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return hasArticleType(value['@graph'])
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function hasMaxImagePreviewLarge(html: string): boolean {
|
|
257
|
+
const metaTags = html.match(/<meta\s+[^>]*>/gi) ?? []
|
|
258
|
+
for (const tag of metaTags) {
|
|
259
|
+
if (getHtmlAttr(tag, 'name')?.toLowerCase() !== 'robots') continue
|
|
260
|
+
const content = getHtmlAttr(tag, 'content')?.toLowerCase() ?? ''
|
|
261
|
+
const directives = content.split(',').map((part) => part.trim())
|
|
262
|
+
if (directives.includes('max-image-preview:large')) return true
|
|
263
|
+
}
|
|
264
|
+
return false
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
268
|
+
return typeof value === 'object' && value !== null
|
|
269
|
+
}
|
|
270
|
+
|
|
180
271
|
function isNoindexMetaRefreshRedirect(html: string): boolean {
|
|
181
272
|
const metaTags = html.match(/<meta\s+[^>]*>/gi) ?? []
|
|
182
273
|
const hasRefresh = metaTags.some(
|