@thenewdynamic/astro-seo 0.1.0 → 0.1.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/package.json +2 -2
- package/src/core/getData.ts +134 -0
- package/src/core/getMetasData.ts +70 -0
- package/src/core/getStructuredData.ts +16 -0
- package/src/core/index.ts +11 -0
- package/src/core/sd.ts +51 -0
- package/src/index.ts +57 -0
- package/src/types.ts +92 -0
- package/src/utils.ts +30 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thenewdynamic/astro-seo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Astro integration for SEO meta tags and structured data",
|
|
6
6
|
"author": "The New Dynamic",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
],
|
|
24
24
|
"files": [
|
|
25
25
|
"dist",
|
|
26
|
-
"src
|
|
26
|
+
"src"
|
|
27
27
|
],
|
|
28
28
|
"publishConfig": {
|
|
29
29
|
"access": "public"
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { SeoUserConfig, SeoEntry, SeoData } from '../types.js'
|
|
2
|
+
import { escapeString, makeAbsUrl, isHome, getExcerpt } from '../utils.js'
|
|
3
|
+
|
|
4
|
+
export const makeGetData = (config: SeoUserConfig) => (entry: SeoEntry): SeoData => {
|
|
5
|
+
const { site, resolveImage, transformEntry } = config
|
|
6
|
+
const { url: baseURL } = site
|
|
7
|
+
const absUrl = makeAbsUrl(baseURL)
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
title: siteTitle,
|
|
11
|
+
description: siteDescription,
|
|
12
|
+
image: siteImage,
|
|
13
|
+
seo: { twitterHandle: siteTwitterHandle } = {},
|
|
14
|
+
prod,
|
|
15
|
+
} = site
|
|
16
|
+
|
|
17
|
+
let {
|
|
18
|
+
title = 'Missing',
|
|
19
|
+
type = 'website',
|
|
20
|
+
_type,
|
|
21
|
+
_updatedAt,
|
|
22
|
+
time_start,
|
|
23
|
+
time_end,
|
|
24
|
+
venue,
|
|
25
|
+
date,
|
|
26
|
+
url,
|
|
27
|
+
description,
|
|
28
|
+
descriptionText,
|
|
29
|
+
locale = 'en_US',
|
|
30
|
+
image,
|
|
31
|
+
authors = [],
|
|
32
|
+
bodyText,
|
|
33
|
+
translation,
|
|
34
|
+
twitterCard = 'summary_large_image',
|
|
35
|
+
twitterHandle = siteTwitterHandle,
|
|
36
|
+
twitterCreatorHandle = siteTwitterHandle,
|
|
37
|
+
} = entry
|
|
38
|
+
|
|
39
|
+
const seo = entry.seo || {}
|
|
40
|
+
const {
|
|
41
|
+
title: seoTitle,
|
|
42
|
+
description: seoDescription,
|
|
43
|
+
image: seoImage,
|
|
44
|
+
canonical: seoCanonical,
|
|
45
|
+
private: seoPrivate = false,
|
|
46
|
+
} = seo
|
|
47
|
+
|
|
48
|
+
type = _type === 'post' ? 'article' : 'website'
|
|
49
|
+
url = url ? absUrl(url) || undefined : undefined
|
|
50
|
+
|
|
51
|
+
const isPrivate = seoPrivate || !(prod?.() ?? true)
|
|
52
|
+
const canonical = seoCanonical || url
|
|
53
|
+
|
|
54
|
+
title = seoTitle
|
|
55
|
+
? seoTitle
|
|
56
|
+
: title
|
|
57
|
+
? escapeString(title as string)
|
|
58
|
+
: siteTitle ?? 'Missing'
|
|
59
|
+
|
|
60
|
+
description = seoDescription
|
|
61
|
+
? seoDescription
|
|
62
|
+
: descriptionText
|
|
63
|
+
? escapeString(descriptionText)
|
|
64
|
+
: description && typeof description === 'string'
|
|
65
|
+
? escapeString(description)
|
|
66
|
+
: bodyText
|
|
67
|
+
? getExcerpt(bodyText, 300)
|
|
68
|
+
: siteDescription
|
|
69
|
+
|
|
70
|
+
let ogTitle = title
|
|
71
|
+
|
|
72
|
+
if (siteTitle && !isHome(entry)) {
|
|
73
|
+
title = `${title} | ${siteTitle}`
|
|
74
|
+
} else if (isHome(entry)) {
|
|
75
|
+
title = site.title
|
|
76
|
+
ogTitle = site.title
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const resolvedSiteImage = siteImage
|
|
80
|
+
image = seoImage || image || resolvedSiteImage
|
|
81
|
+
|
|
82
|
+
let imageAlt = ''
|
|
83
|
+
|
|
84
|
+
if (image && typeof image !== 'string') {
|
|
85
|
+
const img = image as Record<string, unknown>
|
|
86
|
+
imageAlt = img.altText as string ?? ''
|
|
87
|
+
image = resolveImage
|
|
88
|
+
? resolveImage(img, { width: 1000 })
|
|
89
|
+
: (img.src ?? img.url ?? '') as string
|
|
90
|
+
} else if (image && typeof image === 'string') {
|
|
91
|
+
image = baseURL + image
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const languageAlternates = translation
|
|
95
|
+
? [{ href: translation.url, hrefLang: translation.lang }]
|
|
96
|
+
: undefined
|
|
97
|
+
const localeAlternate = translation ? [translation.lang] : undefined
|
|
98
|
+
|
|
99
|
+
let output: SeoData = {
|
|
100
|
+
_type,
|
|
101
|
+
title,
|
|
102
|
+
publishedTime: date,
|
|
103
|
+
modifiedTime: _updatedAt,
|
|
104
|
+
authors: authors?.length
|
|
105
|
+
? authors.map((a) => ({ name: a.title ?? a.name ?? '', url: a.url ? absUrl(a.url) : false }))
|
|
106
|
+
: [],
|
|
107
|
+
description,
|
|
108
|
+
canonical,
|
|
109
|
+
noindex: isPrivate,
|
|
110
|
+
nofollow: isPrivate,
|
|
111
|
+
charset: 'UTF-8',
|
|
112
|
+
ogTitle,
|
|
113
|
+
type,
|
|
114
|
+
image: image as string | undefined,
|
|
115
|
+
imageAlt,
|
|
116
|
+
url,
|
|
117
|
+
locale: locale as string,
|
|
118
|
+
localeAlternate,
|
|
119
|
+
languageAlternates,
|
|
120
|
+
siteTitle: siteTitle ?? '',
|
|
121
|
+
twitterCard: twitterCard as string,
|
|
122
|
+
twitterHandle: twitterHandle as string | undefined,
|
|
123
|
+
twitterCreatorHandle: twitterCreatorHandle as string | undefined,
|
|
124
|
+
venue,
|
|
125
|
+
timeStart: time_start,
|
|
126
|
+
timeEnd: time_end,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (transformEntry) {
|
|
130
|
+
output = { ...output, ...transformEntry(entry) }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return output
|
|
134
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { SeoEntry } from '../types.js'
|
|
2
|
+
import type { makeGetData } from './getData.js'
|
|
3
|
+
|
|
4
|
+
export const makeGetMetasData =
|
|
5
|
+
(getData: ReturnType<typeof makeGetData>) =>
|
|
6
|
+
(entry: SeoEntry): Record<string, unknown> => {
|
|
7
|
+
const {
|
|
8
|
+
title,
|
|
9
|
+
description,
|
|
10
|
+
canonical,
|
|
11
|
+
noindex,
|
|
12
|
+
nofollow,
|
|
13
|
+
charset,
|
|
14
|
+
ogTitle,
|
|
15
|
+
type,
|
|
16
|
+
authors,
|
|
17
|
+
publishedTime,
|
|
18
|
+
modifiedTime,
|
|
19
|
+
image,
|
|
20
|
+
imageAlt,
|
|
21
|
+
url,
|
|
22
|
+
locale,
|
|
23
|
+
localeAlternate,
|
|
24
|
+
languageAlternates,
|
|
25
|
+
siteTitle,
|
|
26
|
+
twitterCard,
|
|
27
|
+
twitterHandle,
|
|
28
|
+
twitterCreatorHandle,
|
|
29
|
+
} = getData(entry)
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
title,
|
|
33
|
+
description,
|
|
34
|
+
canonical,
|
|
35
|
+
noindex,
|
|
36
|
+
nofollow,
|
|
37
|
+
charset,
|
|
38
|
+
languageAlternates,
|
|
39
|
+
openGraph: {
|
|
40
|
+
basic: {
|
|
41
|
+
title: ogTitle,
|
|
42
|
+
type,
|
|
43
|
+
image,
|
|
44
|
+
url,
|
|
45
|
+
},
|
|
46
|
+
optional: {
|
|
47
|
+
locale,
|
|
48
|
+
localeAlternate,
|
|
49
|
+
description,
|
|
50
|
+
siteName: siteTitle,
|
|
51
|
+
},
|
|
52
|
+
image: {
|
|
53
|
+
alt: imageAlt,
|
|
54
|
+
},
|
|
55
|
+
...(type === 'article'
|
|
56
|
+
? {
|
|
57
|
+
publishedTime,
|
|
58
|
+
modifiedTime,
|
|
59
|
+
authors: authors.map((a) => a.name),
|
|
60
|
+
}
|
|
61
|
+
: {}),
|
|
62
|
+
},
|
|
63
|
+
twitter: {
|
|
64
|
+
description,
|
|
65
|
+
card: twitterCard,
|
|
66
|
+
site: twitterHandle ? '@' + twitterHandle : undefined,
|
|
67
|
+
creator: twitterCreatorHandle ? '@' + twitterCreatorHandle : undefined,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SeoEntry } from '../types.js'
|
|
2
|
+
import type { makeGetData } from './getData.js'
|
|
3
|
+
import { parseBase, parseEvent } from './sd.js'
|
|
4
|
+
|
|
5
|
+
export const makeGetStructuredData =
|
|
6
|
+
(getData: ReturnType<typeof makeGetData>) =>
|
|
7
|
+
(entry: SeoEntry): Record<string, unknown> => {
|
|
8
|
+
const data = getData(entry)
|
|
9
|
+
let output = parseBase(data)
|
|
10
|
+
|
|
11
|
+
if (data._type === 'event') {
|
|
12
|
+
output = { ...output, ...parseEvent(data) }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return output
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SeoUserConfig, SeoUtils } from '../types.js'
|
|
2
|
+
import { makeGetData } from './getData.js'
|
|
3
|
+
import { makeGetMetasData } from './getMetasData.js'
|
|
4
|
+
import { makeGetStructuredData } from './getStructuredData.js'
|
|
5
|
+
|
|
6
|
+
export const createSeoUtils = (config: SeoUserConfig): SeoUtils => {
|
|
7
|
+
const getData = makeGetData(config)
|
|
8
|
+
const getMetasData = makeGetMetasData(getData)
|
|
9
|
+
const getStructuredData = makeGetStructuredData(getData)
|
|
10
|
+
return { getData, getMetasData, getStructuredData }
|
|
11
|
+
}
|
package/src/core/sd.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SeoData } from '../types.js'
|
|
2
|
+
|
|
3
|
+
export const parseBase = (data: SeoData): Record<string, unknown> => {
|
|
4
|
+
const { description, ogTitle, type, publishedTime, modifiedTime, image, authors, url } = data
|
|
5
|
+
|
|
6
|
+
return {
|
|
7
|
+
'@context': 'https://schema.org',
|
|
8
|
+
'@type': type,
|
|
9
|
+
headline: ogTitle,
|
|
10
|
+
url,
|
|
11
|
+
image: [image],
|
|
12
|
+
description,
|
|
13
|
+
...(authors?.length ? {
|
|
14
|
+
author: authors.map(({ name, url }) => ({
|
|
15
|
+
'@type': 'Person',
|
|
16
|
+
name,
|
|
17
|
+
url,
|
|
18
|
+
}))
|
|
19
|
+
} : {}),
|
|
20
|
+
...(type === 'article' ? {
|
|
21
|
+
datePublished: publishedTime,
|
|
22
|
+
dateModified: modifiedTime,
|
|
23
|
+
} : {}),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const parseVenue = (data: Record<string, unknown>): Record<string, unknown> => {
|
|
28
|
+
const { title, address_1: address, city, country, state, zip } = data as Record<string, string>
|
|
29
|
+
return {
|
|
30
|
+
'@type': 'Place',
|
|
31
|
+
name: title,
|
|
32
|
+
address: {
|
|
33
|
+
'@type': 'PostalAddress',
|
|
34
|
+
streetAddress: address,
|
|
35
|
+
addressLocality: city,
|
|
36
|
+
postalCode: zip,
|
|
37
|
+
addressRegion: state,
|
|
38
|
+
addressCountry: country,
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const parseEvent = (data: SeoData): Record<string, unknown> => {
|
|
44
|
+
const { timeStart, timeEnd, venue } = data
|
|
45
|
+
return {
|
|
46
|
+
'@type': 'Event',
|
|
47
|
+
startDate: timeStart,
|
|
48
|
+
endDate: timeEnd,
|
|
49
|
+
...(venue ? { location: parseVenue(venue as Record<string, unknown>) } : {}),
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
import type { AstroIntegration } from 'astro'
|
|
4
|
+
import type { Plugin } from 'vite'
|
|
5
|
+
import type { TndSeoOptions } from './types.js'
|
|
6
|
+
|
|
7
|
+
const VIRTUAL_MODULE_ID = 'virtual:tnd/seo'
|
|
8
|
+
const RESOLVED_ID = '\0' + VIRTUAL_MODULE_ID
|
|
9
|
+
|
|
10
|
+
const VIRTUAL_MODULE_TYPES = `
|
|
11
|
+
declare module 'virtual:tnd/seo' {
|
|
12
|
+
import type { SeoEntry, SeoData } from '@thenewdynamic/astro-seo'
|
|
13
|
+
export const getData: (entry: SeoEntry) => SeoData
|
|
14
|
+
export const getMetasData: (entry: SeoEntry) => Record<string, unknown>
|
|
15
|
+
export const getStructuredData: (entry: SeoEntry) => Record<string, unknown>
|
|
16
|
+
}
|
|
17
|
+
`
|
|
18
|
+
|
|
19
|
+
export default function tndSeo(options: TndSeoOptions = {}): AstroIntegration {
|
|
20
|
+
let configFilePath: string
|
|
21
|
+
let coreModulePath: string
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
name: '@thenewdynamic/astro-seo',
|
|
25
|
+
hooks: {
|
|
26
|
+
'astro:config:setup': ({ config, updateConfig }) => {
|
|
27
|
+
const root = fileURLToPath(config.root)
|
|
28
|
+
const userConfigPath = options.configPath ?? './seo.config'
|
|
29
|
+
configFilePath = resolve(root, userConfigPath)
|
|
30
|
+
coreModulePath = fileURLToPath(new URL('./core/index.js', import.meta.url))
|
|
31
|
+
|
|
32
|
+
const plugin: Plugin = {
|
|
33
|
+
name: 'vite-plugin-tnd-seo',
|
|
34
|
+
resolveId(id) {
|
|
35
|
+
if (id === VIRTUAL_MODULE_ID) return RESOLVED_ID
|
|
36
|
+
},
|
|
37
|
+
load(id) {
|
|
38
|
+
if (id !== RESOLVED_ID) return
|
|
39
|
+
return [
|
|
40
|
+
`import userConfig from '${configFilePath}'`,
|
|
41
|
+
`import { createSeoUtils } from '${coreModulePath}'`,
|
|
42
|
+
`export const { getData, getMetasData, getStructuredData } = createSeoUtils(userConfig)`,
|
|
43
|
+
].join('\n')
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
updateConfig({ vite: { plugins: [plugin] } })
|
|
48
|
+
},
|
|
49
|
+
'astro:config:done': ({ injectTypes }) => {
|
|
50
|
+
injectTypes({ filename: 'virtual-tnd-seo.d.ts', content: VIRTUAL_MODULE_TYPES })
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type { SeoUserConfig, SeoEntry, SeoData, SeoUtils, TndSeoOptions } from './types.js'
|
|
57
|
+
export { flattenEntry } from './utils.js'
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export interface SiteConfig {
|
|
2
|
+
url: string
|
|
3
|
+
title: string
|
|
4
|
+
description?: string
|
|
5
|
+
image?: string | Record<string, unknown>
|
|
6
|
+
seo?: {
|
|
7
|
+
title?: string
|
|
8
|
+
twitterHandle?: string
|
|
9
|
+
}
|
|
10
|
+
prod?: () => boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ImageOptions {
|
|
14
|
+
width?: number
|
|
15
|
+
height?: number
|
|
16
|
+
[key: string]: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SeoEntry {
|
|
20
|
+
_type?: string
|
|
21
|
+
title?: string
|
|
22
|
+
type?: string
|
|
23
|
+
_updatedAt?: string
|
|
24
|
+
time_start?: string
|
|
25
|
+
time_end?: string
|
|
26
|
+
venue?: unknown
|
|
27
|
+
date?: string
|
|
28
|
+
url?: string
|
|
29
|
+
description?: string | unknown[]
|
|
30
|
+
descriptionText?: string
|
|
31
|
+
locale?: string
|
|
32
|
+
image?: string | Record<string, unknown>
|
|
33
|
+
authors?: Array<{ title?: string; name?: string; url?: string }>
|
|
34
|
+
bodyText?: string
|
|
35
|
+
translation?: { url: string; lang: string }
|
|
36
|
+
twitterCard?: string
|
|
37
|
+
twitterHandle?: string
|
|
38
|
+
twitterCreatorHandle?: string
|
|
39
|
+
home?: boolean
|
|
40
|
+
seo?: {
|
|
41
|
+
title?: string
|
|
42
|
+
description?: string
|
|
43
|
+
image?: Record<string, unknown>
|
|
44
|
+
canonical?: string
|
|
45
|
+
private?: boolean
|
|
46
|
+
}
|
|
47
|
+
[key: string]: unknown
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SeoData {
|
|
51
|
+
_type?: string
|
|
52
|
+
title: string
|
|
53
|
+
publishedTime?: string
|
|
54
|
+
modifiedTime?: string
|
|
55
|
+
authors: Array<{ name: string; url: string | false }>
|
|
56
|
+
description?: string
|
|
57
|
+
canonical?: string | false
|
|
58
|
+
noindex: boolean
|
|
59
|
+
nofollow: boolean
|
|
60
|
+
charset: string
|
|
61
|
+
ogTitle: string
|
|
62
|
+
type: string
|
|
63
|
+
image?: string | false
|
|
64
|
+
imageAlt: string
|
|
65
|
+
url?: string | false
|
|
66
|
+
locale: string
|
|
67
|
+
localeAlternate?: string[]
|
|
68
|
+
languageAlternates?: Array<{ href: string; hrefLang: string }>
|
|
69
|
+
siteTitle: string
|
|
70
|
+
twitterCard: string
|
|
71
|
+
twitterHandle?: string
|
|
72
|
+
twitterCreatorHandle?: string
|
|
73
|
+
venue?: unknown
|
|
74
|
+
timeStart?: string
|
|
75
|
+
timeEnd?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SeoUserConfig {
|
|
79
|
+
site: SiteConfig
|
|
80
|
+
resolveImage?: (image: Record<string, unknown>, opts?: ImageOptions) => string
|
|
81
|
+
transformEntry?: (entry: SeoEntry) => Partial<SeoData>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface SeoUtils {
|
|
85
|
+
getData: (entry: SeoEntry) => SeoData
|
|
86
|
+
getMetasData: (entry: SeoEntry) => Record<string, unknown>
|
|
87
|
+
getStructuredData: (entry: SeoEntry) => Record<string, unknown>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface TndSeoOptions {
|
|
91
|
+
configPath?: string
|
|
92
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const escapeString = (string: string): string => {
|
|
2
|
+
if (/[*_"]/.test(string)) {
|
|
3
|
+
return string.replace('"', '"').replace(/[*_]/g, '')
|
|
4
|
+
}
|
|
5
|
+
return string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const makeAbsUrl = (baseURL: string) => (url: string): string | false => {
|
|
9
|
+
if (typeof url === 'undefined') return false
|
|
10
|
+
const separator = url.charAt(0) !== '/' ? '/' : ''
|
|
11
|
+
return baseURL + separator + url
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const isHome = (entry: Record<string, unknown>): boolean => {
|
|
15
|
+
return typeof entry.home !== 'undefined' && !!entry.home
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const getExcerpt = (string: string, length = 300): string => {
|
|
19
|
+
if (string && string.length > length) {
|
|
20
|
+
return string.substring(0, length) + '...'
|
|
21
|
+
}
|
|
22
|
+
return string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const flattenEntry = (
|
|
26
|
+
entry: Record<string, unknown> & { data?: Record<string, unknown> }
|
|
27
|
+
): Record<string, unknown> => ({
|
|
28
|
+
...entry,
|
|
29
|
+
...entry.data,
|
|
30
|
+
})
|