@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.
Files changed (68) hide show
  1. package/dist/utils/validation.d.ts.map +1 -1
  2. package/dist/utils/validation.js +22 -0
  3. package/dist/utils/validation.js.map +1 -1
  4. package/package.json +9 -5
  5. package/src/_internal/state.ts +26 -0
  6. package/src/bindings.ts +146 -0
  7. package/src/cron/prune-aeo-r2.ts +140 -0
  8. package/src/durable-objects/aeo-revalidation-coord.ts +246 -0
  9. package/src/index.ts +380 -0
  10. package/src/middleware/seo.ts +350 -0
  11. package/src/options.ts +456 -0
  12. package/src/routes/aeo-twin.ts +130 -0
  13. package/src/routes/apple-news.ts +36 -0
  14. package/src/routes/llms-full.ts +36 -0
  15. package/src/routes/llms.ts +15 -0
  16. package/src/routes/podcast-narration.ts +45 -0
  17. package/src/routes/podcast.ts +27 -0
  18. package/src/routes/revalidate.ts +298 -0
  19. package/src/routes/robots.ts +21 -0
  20. package/src/routes/rss.ts +29 -0
  21. package/src/routes/sitemap-articles.ts +25 -0
  22. package/src/routes/sitemap-index.ts +89 -0
  23. package/src/routes/sitemap-markdown.ts +39 -0
  24. package/src/routes/sitemap-pages.ts +24 -0
  25. package/src/routes/sitemap-products.ts +24 -0
  26. package/src/routes/sitemap-videos.ts +24 -0
  27. package/src/runtime.ts +17 -0
  28. package/src/site-url-core.ts +71 -0
  29. package/src/site-url.ts +21 -0
  30. package/src/types.ts +166 -0
  31. package/src/utils/aeo-summary.ts +176 -0
  32. package/src/utils/aeo-twin-emitter.ts +173 -0
  33. package/src/utils/aeo.ts +223 -0
  34. package/src/utils/apple-news-anf.ts +163 -0
  35. package/src/utils/apple-news-rss.ts +136 -0
  36. package/src/utils/content-filter.ts +87 -0
  37. package/src/utils/crawler-class.ts +155 -0
  38. package/src/utils/define-content-provider.ts +65 -0
  39. package/src/utils/effective-auth.ts +44 -0
  40. package/src/utils/fcrdns.ts +269 -0
  41. package/src/utils/fresh-layer.ts +175 -0
  42. package/src/utils/hreflang.ts +26 -0
  43. package/src/utils/index.ts +91 -0
  44. package/src/utils/json-ld/article.ts +120 -0
  45. package/src/utils/json-ld/audio.ts +32 -0
  46. package/src/utils/json-ld/breadcrumb.ts +28 -0
  47. package/src/utils/json-ld/faq.ts +18 -0
  48. package/src/utils/json-ld/howto.ts +23 -0
  49. package/src/utils/json-ld/index.ts +12 -0
  50. package/src/utils/json-ld/item-list.ts +26 -0
  51. package/src/utils/json-ld/organization.ts +42 -0
  52. package/src/utils/json-ld/person.ts +25 -0
  53. package/src/utils/json-ld/product.ts +155 -0
  54. package/src/utils/json-ld/video.ts +20 -0
  55. package/src/utils/json-ld/website.ts +27 -0
  56. package/src/utils/llms-full.ts +90 -0
  57. package/src/utils/llms.ts +45 -0
  58. package/src/utils/meta.ts +184 -0
  59. package/src/utils/podcast.ts +112 -0
  60. package/src/utils/robots.ts +47 -0
  61. package/src/utils/rss.ts +64 -0
  62. package/src/utils/seo-head.ts +81 -0
  63. package/src/utils/sitemap-markdown.ts +80 -0
  64. package/src/utils/sitemap.ts +169 -0
  65. package/src/utils/staleness.ts +61 -0
  66. package/src/utils/validation.ts +308 -0
  67. package/src/virtual.d.ts +8 -0
  68. 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
+ }