@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.
@@ -105,9 +105,11 @@ ${entries}
105
105
 
106
106
  export function generateVideoSitemap(items: ContentItem[]): string {
107
107
  const entries = items
108
- .filter((item) => item.video)
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
  : ''
@@ -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.push('Missing og:image meta tag')
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
- warnings.push(`Hero image width ${width}px is below minimum ${options.heroMinWidth}px`)
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.push('Missing H1 tag')
180
+ pushIssue(options.requireH1, errors, warnings, 'Missing H1 tag')
167
181
  } else if (h1Matches.length > 1) {
168
- warnings.push(`Multiple H1 tags found (${h1Matches.length})`)
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(