@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thenewdynamic/astro-seo",
3
- "version": "0.1.0",
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/components"
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('"', '&quot;').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
+ })