Skip to content

Commit 63d0706

Browse files
committed
update
1 parent d51783f commit 63d0706

File tree

4 files changed

+276
-17
lines changed

4 files changed

+276
-17
lines changed

src/components/HeadSEO.astro

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Metadata, QuickstartsFrontmatter } from "src/content.config.ts"
33
import { SITE, OPEN_GRAPH, PAGE } from "../config"
44
import { getOgType } from "~/utils/seo/og"
55
import { resolveCanonical, getMimeFromUrlPath, toAbsoluteUrl } from "~/utils/seo/url"
6+
import { enhanceExcerpt } from "~/utils/seo/excerpt"
67
import { detectApiReference } from "@components/VersionSelector/utils/versions"
78
import { extractVersionInfo } from "@components/VersionSelector/utils/extractVersionInfo"
89
import VersionSelectorHead from "@components/VersionSelector/base/VersionSelectorHead.astro"
@@ -23,7 +24,9 @@ const { metadata, canonicalURL, quickstartFrontmatter, pageTitle, howToSteps, su
2324
const contentTitle = pageTitle ?? metadata?.title ?? PAGE.titleFallback
2425
const formattedContentTitle = `${contentTitle} | ${SITE.title}`
2526
const description = metadata?.description ?? SITE.description
26-
const excerpt = metadata?.excerpt ?? description
27+
28+
// Enhance excerpt with path-based keywords (currentPage declared below)
29+
const baseExcerpt = metadata?.excerpt ?? description
2730
2831
// Check that the metadata image property exists, else use OPEN_GRAPH.image.src
2932
var canonicalImageSrc = OPEN_GRAPH.image.src
@@ -42,6 +45,9 @@ ogImageAlt = pageTitle || metadata?.title || OPEN_GRAPH.image.alt
4245
// Detect if this is an API reference page and get the product
4346
const currentPage = new URL(Astro.request.url).pathname
4447
const isCcipDirectoryPath = currentPage.startsWith("/ccip/directory")
48+
49+
// Apply centralized excerpt enhancement
50+
const excerpt = enhanceExcerpt(baseExcerpt, currentPage)
4551
const { isApiReference, product, isVersioned } = detectApiReference(currentPage)
4652
4753
// Extract version information for API reference pages
@@ -62,7 +68,15 @@ const resolvedCanonicalHref = resolveCanonical(metadata?.canonical, Astro.site,
6268
// Contextual Open Graph type
6369
const ogType = getOgType(currentPage)
6470
65-
// Use quickstart frontmatter if available, otherwise use regular metadata
71+
// Create enhanced metadata with improved excerpt for structured data
72+
const enhancedMetadata = metadata
73+
? {
74+
...metadata,
75+
excerpt: excerpt, // Use our centrally enhanced excerpt
76+
}
77+
: metadata
78+
79+
// Use quickstart frontmatter if available, otherwise use enhanced metadata
6680
// Suppress default structured data for CCIP directory pages (handled by page-level generators)
6781
const structuredDataObjects =
6882
suppressDefaultStructuredData || isCcipDirectoryPath
@@ -78,11 +92,11 @@ const structuredDataObjects =
7892
howToSteps
7993
)
8094
: generateStructuredData(
81-
metadata,
95+
enhancedMetadata,
8296
contentTitle,
8397
canonicalURLObj,
8498
currentPage,
85-
metadata?.estimatedTime,
99+
enhancedMetadata?.estimatedTime,
86100
versionInfo,
87101
howToSteps
88102
)

src/utils/seo/entities.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* Extract semantic entities for Schema.org about/mentions properties
3+
* Provides stronger signals than keywords alone by declaring specific entities
4+
*/
5+
6+
export interface SemanticEntity {
7+
"@type": string
8+
name: string
9+
description?: string
10+
sameAs?: string[] // URLs to authoritative sources
11+
}
12+
13+
export interface EntityAnalysis {
14+
about: SemanticEntity[] // Primary topics the content is about
15+
}
16+
17+
/**
18+
* Analyze content to extract relevant entities for stronger SEO signals
19+
*/
20+
export function extractContentEntities(excerpt: string, pathname: string, title?: string): EntityAnalysis {
21+
const entities: EntityAnalysis = {
22+
about: [],
23+
}
24+
25+
// Parse title for direct about entities (simple approach)
26+
if (title) {
27+
const titleWords = title
28+
.toLowerCase()
29+
.replace(/[^\w\s]/g, " ") // Remove punctuation
30+
.split(/\s+/)
31+
.filter((word) => word.length > 2) // Remove short words
32+
33+
// Add title-based about entities
34+
for (const word of titleWords) {
35+
if (!["the", "and", "with", "for", "how", "tutorial", "guide"].includes(word)) {
36+
entities.about.push({
37+
"@type": "Thing",
38+
name: word.charAt(0).toUpperCase() + word.slice(1),
39+
description: `Content about ${word}`,
40+
})
41+
}
42+
}
43+
}
44+
45+
// Focus only on title-based about entities for stronger, maintainable SEO
46+
47+
// No mentions extraction - focus on about entities for stronger signals
48+
return entities
49+
}
50+
51+
/**
52+
* Convert space-separated keywords to comma-separated format for better SEO
53+
*/
54+
export function formatKeywordsForSEO(keywords: string): string {
55+
if (!keywords) return ""
56+
57+
// Split on spaces, remove duplicates, rejoin with commas
58+
const keywordArray = keywords
59+
.split(/\s+/)
60+
.filter((keyword) => keyword.length > 2) // Remove very short words
61+
.filter((keyword, index, array) => array.indexOf(keyword) === index) // Remove duplicates
62+
63+
return keywordArray.join(", ")
64+
}
65+
66+
/**
67+
* Generate enhanced Schema.org properties with entities and formatted keywords
68+
*/
69+
export function generateEnhancedSchemaProperties(
70+
baseProperties: Record<string, unknown>,
71+
excerpt: string,
72+
pathname: string,
73+
title?: string
74+
): Record<string, unknown> {
75+
const entities = extractContentEntities(excerpt, pathname, title)
76+
const formattedKeywords = formatKeywordsForSEO(excerpt)
77+
78+
return {
79+
...baseProperties,
80+
81+
// Enhanced keywords (comma-separated for better parsing)
82+
...(formattedKeywords && {
83+
keywords: formattedKeywords,
84+
}),
85+
86+
// Primary topics (what the content is fundamentally about)
87+
...(entities.about.length > 0 && {
88+
about: entities.about.length === 1 ? entities.about[0] : entities.about,
89+
}),
90+
}
91+
}

src/utils/seo/excerpt.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* Semantic keyword cluster definition
3+
*/
4+
interface SemanticCluster {
5+
coreTerms: string[] // Primary concepts (always include if missing)
6+
synonyms: string[] // Alternative terms (include strategically)
7+
intentPhrases: string[] // User search phrases (high-value additions)
8+
modifiers: string[] // Decision-making keywords (selective inclusion)
9+
}
10+
11+
/**
12+
* Enhance excerpt with path-based keywords for better SEO.
13+
* Automatically appends relevant semantic clusters based on URL path patterns.
14+
*
15+
* @param originalExcerpt - The original excerpt from frontmatter
16+
* @param pathname - The current page pathname
17+
* @returns Enhanced excerpt with comprehensive keyword coverage
18+
*/
19+
export function enhanceExcerpt(originalExcerpt: string, pathname: string): string {
20+
if (!originalExcerpt || !pathname) {
21+
return originalExcerpt || ""
22+
}
23+
24+
// Define semantic keyword clusters for different paths
25+
const pathKeywordClusters = new Map<string, SemanticCluster>([
26+
[
27+
"/ccip/",
28+
{
29+
coreTerms: ["bridge", "token bridge", "cross-chain bridge"],
30+
synonyms: ["crypto bridge", "asset bridge", "blockchain bridge"],
31+
intentPhrases: ["bridge tokens", "cross-chain transfer"],
32+
modifiers: ["secure bridge", "fast bridge", "low fees"],
33+
},
34+
],
35+
36+
// Future expansions:
37+
// ["/vrf/", {
38+
// coreTerms: ["random number generation", "verifiable randomness"],
39+
// synonyms: ["blockchain randomness", "cryptographic random"],
40+
// intentPhrases: ["generate random numbers", "secure randomness"],
41+
// modifiers: ["provably fair", "tamper-proof"]
42+
// }],
43+
])
44+
45+
let enhancedExcerpt = originalExcerpt
46+
47+
// Apply semantic keyword enhancement
48+
for (const [pathPrefix, cluster] of pathKeywordClusters) {
49+
if (pathname.startsWith(pathPrefix)) {
50+
enhancedExcerpt = applySEOCluster(enhancedExcerpt, cluster, pathname)
51+
break // Only apply one cluster per path
52+
}
53+
}
54+
55+
// Clean up and optimize
56+
return optimizeKeywordString(enhancedExcerpt)
57+
}
58+
59+
/**
60+
* Apply semantic cluster to excerpt with intelligent selection
61+
*/
62+
function applySEOCluster(excerpt: string, cluster: SemanticCluster, pathname: string): string {
63+
let enhanced = excerpt
64+
const lowerExcerpt = excerpt.toLowerCase()
65+
66+
// 1. Always add missing core terms (critical for classification)
67+
for (const term of cluster.coreTerms) {
68+
if (!hasKeywordVariant(lowerExcerpt, term)) {
69+
enhanced = `${enhanced} ${term}`
70+
}
71+
}
72+
73+
// 2. Add strategic synonyms (avoid keyword stuffing)
74+
const synonymsToAdd = cluster.synonyms.filter((synonym) => !hasKeywordVariant(lowerExcerpt, synonym)).slice(0, 2) // Limit to 2 synonyms max
75+
76+
for (const synonym of synonymsToAdd) {
77+
enhanced = `${enhanced} ${synonym}`
78+
}
79+
80+
// 3. Add high-value intent phrases (if tutorial/guide content)
81+
if (pathname.includes("/tutorial") || pathname.includes("/guide")) {
82+
const intentToAdd = cluster.intentPhrases.filter((phrase) => !hasKeywordVariant(lowerExcerpt, phrase)).slice(0, 1) // Limit to 1 intent phrase
83+
84+
for (const intent of intentToAdd) {
85+
enhanced = `${enhanced} ${intent}`
86+
}
87+
}
88+
89+
// 4. Add selective modifiers based on content type
90+
if (shouldAddModifiers(pathname)) {
91+
const modifierToAdd = cluster.modifiers.find((modifier) => !hasKeywordVariant(lowerExcerpt, modifier))
92+
93+
if (modifierToAdd) {
94+
enhanced = `${enhanced} ${modifierToAdd}`
95+
}
96+
}
97+
98+
return enhanced
99+
}
100+
101+
/**
102+
* Check if excerpt contains keyword or its variants
103+
*/
104+
function hasKeywordVariant(lowerExcerpt: string, keyword: string): boolean {
105+
const keywordParts = keyword.toLowerCase().split(" ")
106+
107+
// Check for exact phrase
108+
if (lowerExcerpt.includes(keyword.toLowerCase())) {
109+
return true
110+
}
111+
112+
// Check if all keyword parts exist (different order/spacing)
113+
return keywordParts.every((part) => lowerExcerpt.includes(part))
114+
}
115+
116+
/**
117+
* Determine if modifiers should be added based on content context
118+
*/
119+
function shouldAddModifiers(pathname: string): boolean {
120+
// Add modifiers for comparison/selection content
121+
return (
122+
pathname.includes("/tutorial") ||
123+
pathname.includes("/comparison") ||
124+
pathname.includes("/guide") ||
125+
pathname.includes("/best-practices")
126+
)
127+
}
128+
129+
/**
130+
* Optimize the final keyword string for SEO
131+
*/
132+
function optimizeKeywordString(keywords: string): string {
133+
return keywords
134+
.replace(/\s+/g, " ") // Normalize spaces
135+
.trim() // Remove leading/trailing spaces
136+
.substring(0, 500) // Limit total length (SEO best practice)
137+
}

src/utils/structuredData.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import type { Metadata, QuickstartsFrontmatter } from "~/content.config.ts"
10+
import { generateEnhancedSchemaProperties } from "./seo/entities.ts"
1011

1112
/**
1213
* Base URLs - Environment-aware constants
@@ -566,23 +567,36 @@ export function generateTechArticle(
566567
}),
567568
}
568569

570+
// Apply enhanced entity extraction and keyword formatting
571+
const enhancedProperties = generateEnhancedSchemaProperties(
572+
baseArticle,
573+
metadata?.excerpt || "",
574+
pathname,
575+
metadata?.title || title
576+
)
577+
569578
// Add LearningResource properties if applicable
570579
if (isLearningResource) {
571-
return {
572-
...baseArticle,
573-
"@type": ["TechArticle", "LearningResource"],
574-
name: metadata?.title || title, // Required for LearningResource
575-
educationalLevel: difficulty,
576-
teaches: product ? `${category} for ${product}` : category,
577-
learningResourceType: category,
578-
audience: {
579-
"@type": "Audience",
580-
audienceType: difficulty === "Beginner" ? "Beginner" : "Developer",
580+
return generateEnhancedSchemaProperties(
581+
{
582+
...enhancedProperties,
583+
"@type": ["TechArticle", "LearningResource"],
584+
name: metadata?.title || title, // Required for LearningResource
585+
educationalLevel: difficulty,
586+
teaches: product ? `${category} for ${product}` : category,
587+
learningResourceType: category,
588+
audience: {
589+
"@type": "Audience",
590+
audienceType: difficulty === "Beginner" ? "Beginner" : "Developer",
591+
},
581592
},
582-
}
593+
metadata?.excerpt || "",
594+
pathname,
595+
metadata?.title || title
596+
)
583597
}
584598

585-
return baseArticle
599+
return enhancedProperties
586600
}
587601

588602
/**
@@ -608,7 +622,7 @@ export function generateHowTo(
608622
// Generate Schema.org compliant technical properties
609623
const technicalProperties = generateTechnicalProperties(programmingModel, targetPlatform, programmingLanguages)
610624

611-
return {
625+
const baseHowTo = {
612626
"@context": "https://schema.org",
613627
"@type": ["HowTo", "TechArticle"],
614628
name: metadata?.title || title,
@@ -686,6 +700,9 @@ export function generateHowTo(
686700
],
687701
}),
688702
}
703+
704+
// Apply enhanced entity extraction and keyword formatting to HowTo
705+
return generateEnhancedSchemaProperties(baseHowTo, metadata?.excerpt || "", pathname, metadata?.title || title)
689706
}
690707

691708
/**

0 commit comments

Comments
 (0)