@growth-labs/seo 0.4.0 → 0.4.2
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/dist/utils/validation.d.ts.map +1 -1
- package/dist/utils/validation.js +22 -0
- package/dist/utils/validation.js.map +1 -1
- package/package.json +9 -5
- package/src/_internal/state.ts +26 -0
- package/src/bindings.ts +146 -0
- package/src/cron/prune-aeo-r2.ts +140 -0
- package/src/durable-objects/aeo-revalidation-coord.ts +246 -0
- package/src/index.ts +380 -0
- package/src/middleware/seo.ts +350 -0
- package/src/options.ts +456 -0
- package/src/routes/aeo-twin.ts +130 -0
- package/src/routes/apple-news.ts +36 -0
- package/src/routes/llms-full.ts +36 -0
- package/src/routes/llms.ts +15 -0
- package/src/routes/podcast-narration.ts +45 -0
- package/src/routes/podcast.ts +27 -0
- package/src/routes/revalidate.ts +298 -0
- package/src/routes/robots.ts +21 -0
- package/src/routes/rss.ts +29 -0
- package/src/routes/sitemap-articles.ts +25 -0
- package/src/routes/sitemap-index.ts +89 -0
- package/src/routes/sitemap-markdown.ts +39 -0
- package/src/routes/sitemap-pages.ts +24 -0
- package/src/routes/sitemap-products.ts +24 -0
- package/src/routes/sitemap-videos.ts +24 -0
- package/src/runtime.ts +17 -0
- package/src/site-url-core.ts +71 -0
- package/src/site-url.ts +21 -0
- package/src/types.ts +166 -0
- package/src/utils/aeo-summary.ts +176 -0
- package/src/utils/aeo-twin-emitter.ts +173 -0
- package/src/utils/aeo.ts +223 -0
- package/src/utils/apple-news-anf.ts +163 -0
- package/src/utils/apple-news-rss.ts +136 -0
- package/src/utils/content-filter.ts +87 -0
- package/src/utils/crawler-class.ts +155 -0
- package/src/utils/define-content-provider.ts +65 -0
- package/src/utils/effective-auth.ts +44 -0
- package/src/utils/fcrdns.ts +269 -0
- package/src/utils/fresh-layer.ts +175 -0
- package/src/utils/hreflang.ts +26 -0
- package/src/utils/index.ts +91 -0
- package/src/utils/json-ld/article.ts +120 -0
- package/src/utils/json-ld/audio.ts +32 -0
- package/src/utils/json-ld/breadcrumb.ts +28 -0
- package/src/utils/json-ld/faq.ts +18 -0
- package/src/utils/json-ld/howto.ts +23 -0
- package/src/utils/json-ld/index.ts +12 -0
- package/src/utils/json-ld/item-list.ts +26 -0
- package/src/utils/json-ld/organization.ts +42 -0
- package/src/utils/json-ld/person.ts +25 -0
- package/src/utils/json-ld/product.ts +155 -0
- package/src/utils/json-ld/video.ts +20 -0
- package/src/utils/json-ld/website.ts +27 -0
- package/src/utils/llms-full.ts +90 -0
- package/src/utils/llms.ts +45 -0
- package/src/utils/meta.ts +184 -0
- package/src/utils/podcast.ts +112 -0
- package/src/utils/robots.ts +47 -0
- package/src/utils/rss.ts +64 -0
- package/src/utils/seo-head.ts +81 -0
- package/src/utils/sitemap-markdown.ts +80 -0
- package/src/utils/sitemap.ts +169 -0
- package/src/utils/staleness.ts +61 -0
- package/src/utils/validation.ts +308 -0
- package/src/virtual.d.ts +8 -0
- package/src/vite-plugin.ts +66 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { KVNamespaceLike, R2BucketLike } from '../bindings.js'
|
|
2
|
+
|
|
3
|
+
// Fresh-twin layer: unified reader/writer over R2 or KV. Keys are deploymentId-
|
|
4
|
+
// prefixed so a rollback (which cuts a new deployment under a different versionId)
|
|
5
|
+
// automatically scopes R2 visibility to the current deployment.
|
|
6
|
+
//
|
|
7
|
+
// Key format: `twin/<deploymentId>/<slug>[.summary].md`
|
|
8
|
+
// Example: `twin/7b9c1f.../article/midway.md`
|
|
9
|
+
|
|
10
|
+
export interface FreshLayerBinding {
|
|
11
|
+
type: 'r2' | 'kv'
|
|
12
|
+
impl: R2BucketLike | KVNamespaceLike
|
|
13
|
+
deploymentId: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FreshTwinReadResult {
|
|
17
|
+
body: string
|
|
18
|
+
contentType: string
|
|
19
|
+
lastModified?: Date
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Public API ───
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read a twin from the fresh layer for the current deployment. Returns null when
|
|
26
|
+
* no entry exists (cache miss — caller should fall back to Assets or contentProvider).
|
|
27
|
+
*
|
|
28
|
+
* Path is the URL-path form (e.g. '/article/midway.md'), not a full URL.
|
|
29
|
+
*/
|
|
30
|
+
export async function readFreshTwin(
|
|
31
|
+
binding: FreshLayerBinding,
|
|
32
|
+
path: string,
|
|
33
|
+
): Promise<FreshTwinReadResult | null> {
|
|
34
|
+
const key = buildKey(binding.deploymentId, path)
|
|
35
|
+
if (binding.type === 'r2') {
|
|
36
|
+
const r2 = binding.impl as R2BucketLike
|
|
37
|
+
const obj = await r2.get(key)
|
|
38
|
+
if (!obj) return null
|
|
39
|
+
return {
|
|
40
|
+
body: await obj.text(),
|
|
41
|
+
contentType: obj.httpMetadata?.contentType ?? 'text/markdown; charset=utf-8',
|
|
42
|
+
lastModified: obj.uploaded,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const kv = binding.impl as KVNamespaceLike
|
|
46
|
+
const body = await kv.get(key)
|
|
47
|
+
if (body === null) return null
|
|
48
|
+
return { body, contentType: 'text/markdown; charset=utf-8' }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Write a twin to the fresh layer under the current deployment prefix. Called
|
|
53
|
+
* from the revalidation endpoint (POST /_seo/revalidate) and from the middleware
|
|
54
|
+
* fallthrough path after a successful on-demand render.
|
|
55
|
+
*/
|
|
56
|
+
export async function writeFreshTwin(
|
|
57
|
+
binding: FreshLayerBinding,
|
|
58
|
+
path: string,
|
|
59
|
+
body: string,
|
|
60
|
+
contentType = 'text/markdown; charset=utf-8',
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
const key = buildKey(binding.deploymentId, path)
|
|
63
|
+
if (binding.type === 'r2') {
|
|
64
|
+
const r2 = binding.impl as R2BucketLike
|
|
65
|
+
await r2.put(key, body, { httpMetadata: { contentType } })
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
const kv = binding.impl as KVNamespaceLike
|
|
69
|
+
await kv.put(key, body)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Delete a twin (and its summary) from the fresh layer. Used by the unpublish
|
|
74
|
+
* path in /_seo/revalidate.
|
|
75
|
+
*/
|
|
76
|
+
export async function deleteFreshTwin(binding: FreshLayerBinding, path: string): Promise<void> {
|
|
77
|
+
const key = buildKey(binding.deploymentId, path)
|
|
78
|
+
if (binding.type === 'r2') {
|
|
79
|
+
const r2 = binding.impl as R2BucketLike
|
|
80
|
+
await r2.delete(key)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
const kv = binding.impl as KVNamespaceLike
|
|
84
|
+
await kv.delete(key)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* List keys for a specific deployment prefix. Used by the prune cron to find
|
|
89
|
+
* and delete entries from old deployments.
|
|
90
|
+
*/
|
|
91
|
+
export async function* listKeysByDeployment(
|
|
92
|
+
binding: FreshLayerBinding,
|
|
93
|
+
deploymentId: string,
|
|
94
|
+
): AsyncGenerator<string> {
|
|
95
|
+
const prefix = `twin/${deploymentId}/`
|
|
96
|
+
if (binding.type === 'r2') {
|
|
97
|
+
const r2 = binding.impl as R2BucketLike
|
|
98
|
+
let cursor: string | undefined
|
|
99
|
+
while (true) {
|
|
100
|
+
const result = await r2.list({ prefix, cursor })
|
|
101
|
+
for (const obj of result.objects) yield obj.key
|
|
102
|
+
if (!result.truncated) break
|
|
103
|
+
cursor = result.cursor
|
|
104
|
+
}
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
const kv = binding.impl as KVNamespaceLike
|
|
108
|
+
let cursor: string | undefined
|
|
109
|
+
while (true) {
|
|
110
|
+
const result = await kv.list({ prefix, cursor })
|
|
111
|
+
for (const entry of result.keys) yield entry.name
|
|
112
|
+
if (result.list_complete) break
|
|
113
|
+
cursor = result.cursor
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* List distinct deployment IDs currently present in the fresh layer. Used by
|
|
119
|
+
* the prune cron to find non-current deployments eligible for cleanup.
|
|
120
|
+
*
|
|
121
|
+
* Implementation note: neither R2 nor KV has a first-class "list top-level
|
|
122
|
+
* prefixes" API. We walk the top-level prefix `twin/` and parse the segment
|
|
123
|
+
* between the second and third slash. For sites with hundreds of old
|
|
124
|
+
* deployments this is O(N) in total key count — acceptable for the nightly
|
|
125
|
+
* cron, too expensive for the request path.
|
|
126
|
+
*/
|
|
127
|
+
export async function listDeploymentIds(binding: FreshLayerBinding): Promise<Set<string>> {
|
|
128
|
+
const deployments = new Set<string>()
|
|
129
|
+
if (binding.type === 'r2') {
|
|
130
|
+
const r2 = binding.impl as R2BucketLike
|
|
131
|
+
let cursor: string | undefined
|
|
132
|
+
while (true) {
|
|
133
|
+
const result = await r2.list({ prefix: 'twin/', cursor })
|
|
134
|
+
for (const obj of result.objects) {
|
|
135
|
+
const id = extractDeploymentId(obj.key)
|
|
136
|
+
if (id) deployments.add(id)
|
|
137
|
+
}
|
|
138
|
+
if (!result.truncated) break
|
|
139
|
+
cursor = result.cursor
|
|
140
|
+
}
|
|
141
|
+
return deployments
|
|
142
|
+
}
|
|
143
|
+
const kv = binding.impl as KVNamespaceLike
|
|
144
|
+
let cursor: string | undefined
|
|
145
|
+
while (true) {
|
|
146
|
+
const result = await kv.list({ prefix: 'twin/', cursor })
|
|
147
|
+
for (const entry of result.keys) {
|
|
148
|
+
const id = extractDeploymentId(entry.name)
|
|
149
|
+
if (id) deployments.add(id)
|
|
150
|
+
}
|
|
151
|
+
if (result.list_complete) break
|
|
152
|
+
cursor = result.cursor
|
|
153
|
+
}
|
|
154
|
+
return deployments
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ─── Internals ───
|
|
158
|
+
|
|
159
|
+
function buildKey(deploymentId: string, path: string): string {
|
|
160
|
+
const normalized = path.startsWith('/') ? path.slice(1) : path
|
|
161
|
+
return `twin/${deploymentId}/${normalized}`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function extractDeploymentId(key: string): string | null {
|
|
165
|
+
// `twin/<id>/<rest>` — second segment.
|
|
166
|
+
const parts = key.split('/')
|
|
167
|
+
if (parts.length < 3) return null
|
|
168
|
+
if (parts[0] !== 'twin') return null
|
|
169
|
+
return parts[1] ?? null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const _internals = {
|
|
173
|
+
buildKey,
|
|
174
|
+
extractDeploymentId,
|
|
175
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ContentLocaleAlternate, HreflangLink } from '../types.js'
|
|
2
|
+
|
|
3
|
+
export function generateHreflang(
|
|
4
|
+
alternates: ContentLocaleAlternate[],
|
|
5
|
+
defaultLocale: string,
|
|
6
|
+
): HreflangLink[] {
|
|
7
|
+
if (!alternates.length) return []
|
|
8
|
+
|
|
9
|
+
const links: HreflangLink[] = alternates.map((alt) => ({
|
|
10
|
+
rel: 'alternate',
|
|
11
|
+
hreflang: alt.lang,
|
|
12
|
+
href: alt.url,
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
// Add x-default pointing to the URL of the defaultLocale
|
|
16
|
+
const defaultEntry = alternates.find((a) => a.lang === defaultLocale)
|
|
17
|
+
if (defaultEntry) {
|
|
18
|
+
links.push({
|
|
19
|
+
rel: 'alternate',
|
|
20
|
+
hreflang: 'x-default',
|
|
21
|
+
href: defaultEntry.url,
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return links
|
|
26
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export {
|
|
2
|
+
estimateTokenCount,
|
|
3
|
+
type GenerateAeoMarkdownOptions,
|
|
4
|
+
generateAeoMarkdown,
|
|
5
|
+
} from './aeo.js'
|
|
6
|
+
export {
|
|
7
|
+
type GenerateSummaryTwinOptions,
|
|
8
|
+
type GenerateSummaryTwinResult,
|
|
9
|
+
generateSummaryTwin,
|
|
10
|
+
} from './aeo-summary.js'
|
|
11
|
+
export {
|
|
12
|
+
type EmitAeoTwinsOptions,
|
|
13
|
+
type EmitAeoTwinsResult,
|
|
14
|
+
type EmittedTwin,
|
|
15
|
+
emitAeoTwins,
|
|
16
|
+
type RenderBody,
|
|
17
|
+
} from './aeo-twin-emitter.js'
|
|
18
|
+
export {
|
|
19
|
+
type AnfComponent,
|
|
20
|
+
type AnfDocument,
|
|
21
|
+
type GenerateAnfOptions,
|
|
22
|
+
generateAppleNewsAnf,
|
|
23
|
+
} from './apple-news-anf.js'
|
|
24
|
+
export {
|
|
25
|
+
type ContentHtmlResolver,
|
|
26
|
+
type GenerateAppleNewsRssOptions,
|
|
27
|
+
generateAppleNewsRss,
|
|
28
|
+
} from './apple-news-rss.js'
|
|
29
|
+
export * from './content-filter.js'
|
|
30
|
+
export {
|
|
31
|
+
type ClassifyRequestInput,
|
|
32
|
+
classifyRequest,
|
|
33
|
+
VERIFIED_SEARCH_SUFFIXES,
|
|
34
|
+
} from './crawler-class.js'
|
|
35
|
+
export {
|
|
36
|
+
type ContentItemByType,
|
|
37
|
+
defineContentProvider,
|
|
38
|
+
defineContentProviderModule,
|
|
39
|
+
type TypedContentProvider,
|
|
40
|
+
} from './define-content-provider.js'
|
|
41
|
+
export { computeEffectiveAuthSegment, type RawAuthSegment } from './effective-auth.js'
|
|
42
|
+
export {
|
|
43
|
+
createDohResolver,
|
|
44
|
+
createFcrdnsVerifier,
|
|
45
|
+
type DnsAnswer,
|
|
46
|
+
type DnsResolver,
|
|
47
|
+
type FcrdnsVerifier,
|
|
48
|
+
type FcrdnsVerifyInput,
|
|
49
|
+
} from './fcrdns.js'
|
|
50
|
+
export {
|
|
51
|
+
deleteFreshTwin,
|
|
52
|
+
type FreshLayerBinding,
|
|
53
|
+
type FreshTwinReadResult,
|
|
54
|
+
listDeploymentIds,
|
|
55
|
+
listKeysByDeployment,
|
|
56
|
+
readFreshTwin,
|
|
57
|
+
writeFreshTwin,
|
|
58
|
+
} from './fresh-layer.js'
|
|
59
|
+
export { generateHreflang } from './hreflang.js'
|
|
60
|
+
export * from './json-ld/index.js'
|
|
61
|
+
export { generateLlmsTxt } from './llms.js'
|
|
62
|
+
export { type GenerateLlmsFullOptions, generateLlmsFull } from './llms-full.js'
|
|
63
|
+
export { applyTrailingSlash, generateCanonical, generateMeta, type OgVariant } from './meta.js'
|
|
64
|
+
export { generatePodcastFeed } from './podcast.js'
|
|
65
|
+
export { generateRobotsTxt } from './robots.js'
|
|
66
|
+
export { generateRssFeed } from './rss.js'
|
|
67
|
+
export { buildSeoHeadJsonLd, buildSeoHeadTitleDescription } from './seo-head.js'
|
|
68
|
+
export {
|
|
69
|
+
generateArticleSitemap,
|
|
70
|
+
generatePagesSitemap,
|
|
71
|
+
generateProductSitemap,
|
|
72
|
+
generateSitemapIndex,
|
|
73
|
+
generateVideoSitemap,
|
|
74
|
+
type SitemapEntry,
|
|
75
|
+
} from './sitemap.js'
|
|
76
|
+
export { generateMarkdownSitemap } from './sitemap-markdown.js'
|
|
77
|
+
export {
|
|
78
|
+
checkStaleness,
|
|
79
|
+
computeContentHash,
|
|
80
|
+
type StalenessDriftRecord,
|
|
81
|
+
} from './staleness.js'
|
|
82
|
+
export {
|
|
83
|
+
type HreflangReciprocityIssue,
|
|
84
|
+
type PageValidationOptions,
|
|
85
|
+
type PrerenderGuardIssue,
|
|
86
|
+
type ValidationResult,
|
|
87
|
+
validateHreflangReciprocity,
|
|
88
|
+
validateJsonLd,
|
|
89
|
+
validatePage,
|
|
90
|
+
validatePrerenderedGatedRoutes,
|
|
91
|
+
} from './validation.js'
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { ResolvedSeoOptions } from '../../options.js'
|
|
2
|
+
import type { ContentItem, JsonLdObject } from '../../types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Redaction mode for gated content.
|
|
6
|
+
* 'full' — no redaction. Used for verified search crawlers under Flexible Sampling
|
|
7
|
+
* and for the member's own request.
|
|
8
|
+
* 'redacted' — members-safe output: description truncated, articleBody absent, FAQ
|
|
9
|
+
* answers truncated. Google Rich Results still passes (description is
|
|
10
|
+
* truncated, not omitted).
|
|
11
|
+
*/
|
|
12
|
+
export type ArticleRenderMode = 'full' | 'redacted'
|
|
13
|
+
|
|
14
|
+
const MAX_REDACTED_DESCRIPTION_CHARS = 160
|
|
15
|
+
|
|
16
|
+
export interface GenerateArticleJsonLdOptions {
|
|
17
|
+
mode?: ArticleRenderMode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function generateArticleJsonLd(
|
|
21
|
+
item: ContentItem,
|
|
22
|
+
options: ResolvedSeoOptions,
|
|
23
|
+
renderOptions: GenerateArticleJsonLdOptions = {},
|
|
24
|
+
): JsonLdObject {
|
|
25
|
+
const { organization, schemaType, audioNarration } = options
|
|
26
|
+
const mode = renderOptions.mode ?? 'full'
|
|
27
|
+
const isMemberItem = item.access === 'members'
|
|
28
|
+
const applyRedaction = mode === 'redacted' && isMemberItem
|
|
29
|
+
|
|
30
|
+
const authors: JsonLdObject[] = (item.authors ?? []).map((author) => {
|
|
31
|
+
const person: JsonLdObject = { '@type': 'Person', name: author.name }
|
|
32
|
+
if (author.url) person.url = author.url
|
|
33
|
+
if (author.jobTitle) person.jobTitle = author.jobTitle
|
|
34
|
+
if (author.knowsAbout?.length) person.knowsAbout = author.knowsAbout
|
|
35
|
+
if (author.sameAs?.length) person.sameAs = author.sameAs
|
|
36
|
+
return person
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Derive isAccessibleForFree: explicit field wins, otherwise derive from access.
|
|
40
|
+
const isAccessibleForFree = item.isAccessibleForFree ?? item.access !== 'members'
|
|
41
|
+
|
|
42
|
+
const result: JsonLdObject = {
|
|
43
|
+
'@context': 'https://schema.org',
|
|
44
|
+
'@type': schemaType,
|
|
45
|
+
headline: item.title,
|
|
46
|
+
url: item.url,
|
|
47
|
+
mainEntityOfPage: {
|
|
48
|
+
'@type': 'WebPage',
|
|
49
|
+
'@id': item.url,
|
|
50
|
+
},
|
|
51
|
+
publisher: {
|
|
52
|
+
'@type': 'Organization',
|
|
53
|
+
name: organization.name,
|
|
54
|
+
logo: {
|
|
55
|
+
'@type': 'ImageObject',
|
|
56
|
+
url: organization.logo,
|
|
57
|
+
},
|
|
58
|
+
...(organization.sameAs?.length ? { sameAs: organization.sameAs } : {}),
|
|
59
|
+
},
|
|
60
|
+
author: authors,
|
|
61
|
+
// Google requires the string form 'True'/'False' for Rich Results when paywall markup is emitted.
|
|
62
|
+
// See https://developers.google.com/search/docs/appearance/structured-data/paywalled-content
|
|
63
|
+
isAccessibleForFree: isAccessibleForFree ? 'True' : 'False',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (item.image) {
|
|
67
|
+
// Consumers compose @growth-labs/media if they need multi-aspect-ratio variants;
|
|
68
|
+
// this utility takes whatever URL(s) the consumer supplied in the ContentItem.
|
|
69
|
+
result.image = Array.isArray(item.image) ? item.image : [item.image]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (item.description) {
|
|
73
|
+
result.description = applyRedaction
|
|
74
|
+
? truncateDescription(item.description, MAX_REDACTED_DESCRIPTION_CHARS)
|
|
75
|
+
: item.description
|
|
76
|
+
}
|
|
77
|
+
if (item.datePublished) result.datePublished = item.datePublished
|
|
78
|
+
if (item.dateModified) result.dateModified = item.dateModified
|
|
79
|
+
|
|
80
|
+
// hasPart paywall marker — emitted whenever the item is gated (either via explicit
|
|
81
|
+
// isAccessibleForFree: false or via access: 'members').
|
|
82
|
+
if (!isAccessibleForFree && item.paywallCssSelector) {
|
|
83
|
+
result.hasPart = {
|
|
84
|
+
'@type': 'WebPageElement',
|
|
85
|
+
isAccessibleForFree: 'False',
|
|
86
|
+
cssSelector: item.paywallCssSelector,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (item.audio) {
|
|
91
|
+
result.associatedMedia = {
|
|
92
|
+
'@type': 'AudioObject',
|
|
93
|
+
contentUrl: item.audio.url,
|
|
94
|
+
duration: item.audio.duration,
|
|
95
|
+
...(item.audio.narrator ? { name: item.audio.narrator } : {}),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (audioNarration?.enabled) {
|
|
100
|
+
result.speakable = {
|
|
101
|
+
'@type': 'SpeakableSpecification',
|
|
102
|
+
cssSelector: audioNarration.speakableSelectors,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return result
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Truncate a description to `maxChars` graphemes, on a word boundary if possible,
|
|
111
|
+
* with ellipsis suffix. Returns the original if shorter than maxChars.
|
|
112
|
+
*/
|
|
113
|
+
function truncateDescription(text: string, maxChars: number): string {
|
|
114
|
+
if (text.length <= maxChars) return text
|
|
115
|
+
const targetLen = maxChars - 1 // reserve for ellipsis
|
|
116
|
+
const sliced = text.slice(0, targetLen)
|
|
117
|
+
const lastSpace = sliced.lastIndexOf(' ')
|
|
118
|
+
const cutAt = lastSpace > targetLen * 0.6 ? lastSpace : targetLen
|
|
119
|
+
return `${sliced.slice(0, cutAt).trimEnd()}…`
|
|
120
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ContentAudio, JsonLdObject } from '../../types.js'
|
|
2
|
+
|
|
3
|
+
export function generateAudioJsonLd(
|
|
4
|
+
audio: ContentAudio,
|
|
5
|
+
options: { title: string; datePublished?: string; articleUrl?: string },
|
|
6
|
+
): JsonLdObject {
|
|
7
|
+
const result: JsonLdObject = {
|
|
8
|
+
'@context': 'https://schema.org',
|
|
9
|
+
'@type': 'AudioObject',
|
|
10
|
+
name: options.title,
|
|
11
|
+
contentUrl: audio.url,
|
|
12
|
+
duration: audio.duration,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (audio.narrator) {
|
|
16
|
+
result.creator = {
|
|
17
|
+
'@type': 'Person',
|
|
18
|
+
name: audio.narrator,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (options.datePublished) result.datePublished = options.datePublished
|
|
23
|
+
|
|
24
|
+
if (options.articleUrl) {
|
|
25
|
+
result.isPartOf = {
|
|
26
|
+
'@type': 'Article',
|
|
27
|
+
url: options.articleUrl,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return result
|
|
32
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { JsonLdObject } from '../../types.js'
|
|
2
|
+
|
|
3
|
+
export interface BreadcrumbItem {
|
|
4
|
+
name: string
|
|
5
|
+
url: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function generateBreadcrumbJsonLd(items: BreadcrumbItem[]): JsonLdObject {
|
|
9
|
+
const lastIndex = items.length - 1
|
|
10
|
+
|
|
11
|
+
const itemListElement = items.map((item, index) => {
|
|
12
|
+
const listItem: JsonLdObject = {
|
|
13
|
+
'@type': 'ListItem',
|
|
14
|
+
position: index + 1,
|
|
15
|
+
name: item.name,
|
|
16
|
+
}
|
|
17
|
+
if (index !== lastIndex) {
|
|
18
|
+
listItem.item = item.url
|
|
19
|
+
}
|
|
20
|
+
return listItem
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
'@context': 'https://schema.org',
|
|
25
|
+
'@type': 'BreadcrumbList',
|
|
26
|
+
itemListElement,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { JsonLdObject } from '../../types.js'
|
|
2
|
+
|
|
3
|
+
export function generateFaqJsonLd(
|
|
4
|
+
items: Array<{ question: string; answer: string }>,
|
|
5
|
+
): JsonLdObject {
|
|
6
|
+
return {
|
|
7
|
+
'@context': 'https://schema.org',
|
|
8
|
+
'@type': 'FAQPage',
|
|
9
|
+
mainEntity: items.map((item) => ({
|
|
10
|
+
'@type': 'Question',
|
|
11
|
+
name: item.question,
|
|
12
|
+
acceptedAnswer: {
|
|
13
|
+
'@type': 'Answer',
|
|
14
|
+
text: item.answer,
|
|
15
|
+
},
|
|
16
|
+
})),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { JsonLdObject } from '../../types.js'
|
|
2
|
+
|
|
3
|
+
export function generateHowToJsonLd(
|
|
4
|
+
name: string,
|
|
5
|
+
description: string,
|
|
6
|
+
steps: Array<{ name: string; text: string; image?: string }>,
|
|
7
|
+
): JsonLdObject {
|
|
8
|
+
return {
|
|
9
|
+
'@context': 'https://schema.org',
|
|
10
|
+
'@type': 'HowTo',
|
|
11
|
+
name,
|
|
12
|
+
description,
|
|
13
|
+
step: steps.map((step) => {
|
|
14
|
+
const s: JsonLdObject = {
|
|
15
|
+
'@type': 'HowToStep',
|
|
16
|
+
name: step.name,
|
|
17
|
+
text: step.text,
|
|
18
|
+
}
|
|
19
|
+
if (step.image) s.image = step.image
|
|
20
|
+
return s
|
|
21
|
+
}),
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { generateArticleJsonLd } from './article.js'
|
|
2
|
+
export { generateAudioJsonLd } from './audio.js'
|
|
3
|
+
export type { BreadcrumbItem } from './breadcrumb.js'
|
|
4
|
+
export { generateBreadcrumbJsonLd } from './breadcrumb.js'
|
|
5
|
+
export { generateFaqJsonLd } from './faq.js'
|
|
6
|
+
export { generateHowToJsonLd } from './howto.js'
|
|
7
|
+
export { generateItemListJsonLd } from './item-list.js'
|
|
8
|
+
export { generateOrganizationJsonLd } from './organization.js'
|
|
9
|
+
export { generatePersonJsonLd } from './person.js'
|
|
10
|
+
export { generateProductGroupJsonLd, generateProductJsonLd } from './product.js'
|
|
11
|
+
export { generateVideoJsonLd } from './video.js'
|
|
12
|
+
export { generateWebSiteJsonLd } from './website.js'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { JsonLdObject } from '../../types.js'
|
|
2
|
+
|
|
3
|
+
const ORDER_MAP: Record<string, string> = {
|
|
4
|
+
ascending: 'https://schema.org/ItemListOrderAscending',
|
|
5
|
+
descending: 'https://schema.org/ItemListOrderDescending',
|
|
6
|
+
unordered: 'https://schema.org/ItemListUnordered',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function generateItemListJsonLd(
|
|
10
|
+
name: string,
|
|
11
|
+
items: Array<{ url: string }>,
|
|
12
|
+
order: 'ascending' | 'descending' | 'unordered' = 'ascending',
|
|
13
|
+
): JsonLdObject {
|
|
14
|
+
return {
|
|
15
|
+
'@context': 'https://schema.org',
|
|
16
|
+
'@type': 'ItemList',
|
|
17
|
+
name,
|
|
18
|
+
itemListOrder: ORDER_MAP[order],
|
|
19
|
+
numberOfItems: items.length,
|
|
20
|
+
itemListElement: items.map((item, index) => ({
|
|
21
|
+
'@type': 'ListItem',
|
|
22
|
+
position: index + 1,
|
|
23
|
+
url: item.url,
|
|
24
|
+
})),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SeoOptionsWithResolvedSite } from '../../options.js'
|
|
2
|
+
import type { JsonLdObject } from '../../types.js'
|
|
3
|
+
|
|
4
|
+
export function generateOrganizationJsonLd(options: SeoOptionsWithResolvedSite): JsonLdObject {
|
|
5
|
+
const { organization, site, commerce } = options
|
|
6
|
+
|
|
7
|
+
const result: JsonLdObject = {
|
|
8
|
+
'@context': 'https://schema.org',
|
|
9
|
+
'@type': 'Organization',
|
|
10
|
+
name: organization.name,
|
|
11
|
+
url: organization.url ?? site,
|
|
12
|
+
logo: {
|
|
13
|
+
'@type': 'ImageObject',
|
|
14
|
+
url: organization.logo,
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (organization.sameAs?.length) {
|
|
19
|
+
result.sameAs = organization.sameAs
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (commerce?.enabled && commerce.returnPolicy) {
|
|
23
|
+
const policy = commerce.returnPolicy
|
|
24
|
+
const policyObj: JsonLdObject = {
|
|
25
|
+
'@type': 'MerchantReturnPolicy',
|
|
26
|
+
applicableCountry: policy.applicableCountry,
|
|
27
|
+
returnPolicyCategory: `https://schema.org/${policy.returnPolicyCategory}`,
|
|
28
|
+
}
|
|
29
|
+
if (policy.merchantReturnDays !== undefined) {
|
|
30
|
+
policyObj.merchantReturnDays = policy.merchantReturnDays
|
|
31
|
+
}
|
|
32
|
+
if (policy.returnMethod) {
|
|
33
|
+
policyObj.returnMethod = `https://schema.org/${policy.returnMethod}`
|
|
34
|
+
}
|
|
35
|
+
if (policy.returnFees) {
|
|
36
|
+
policyObj.returnFees = `https://schema.org/${policy.returnFees}`
|
|
37
|
+
}
|
|
38
|
+
result.hasMerchantReturnPolicy = policyObj
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ContentAuthor, JsonLdObject } from '../../types.js'
|
|
2
|
+
|
|
3
|
+
export function generatePersonJsonLd(
|
|
4
|
+
author: ContentAuthor,
|
|
5
|
+
options?: { image?: string; organizationName?: string },
|
|
6
|
+
): JsonLdObject {
|
|
7
|
+
const result: JsonLdObject = {
|
|
8
|
+
'@context': 'https://schema.org',
|
|
9
|
+
'@type': 'Person',
|
|
10
|
+
name: author.name,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (author.url) result.url = author.url
|
|
14
|
+
if (author.jobTitle) result.jobTitle = author.jobTitle
|
|
15
|
+
if (author.sameAs?.length) result.sameAs = author.sameAs
|
|
16
|
+
if (options?.image) result.image = options.image
|
|
17
|
+
if (options?.organizationName) {
|
|
18
|
+
result.worksFor = {
|
|
19
|
+
'@type': 'Organization',
|
|
20
|
+
name: options.organizationName,
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return result
|
|
25
|
+
}
|