diff --git a/.env.example b/.env.example index df4a80c1..b4ef78db 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,6 @@ VITE_PUBLIC_KEY= # --- The api host directory ENV_API_LOCAL_DIR= + +# --- Public site URL used for canonical links (no trailing slash) +VITE_SITE_URL=http://localhost:5173 diff --git a/caddy/WebCaddyfile.internal b/caddy/WebCaddyfile.internal index 6fe986cd..b914d24b 100644 --- a/caddy/WebCaddyfile.internal +++ b/caddy/WebCaddyfile.internal @@ -1,7 +1,3 @@ -{ - debug -} - :80 { @relay_get { path /relay/* diff --git a/index.html b/index.html index 5965519a..a082134c 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,35 @@ - Gustavo Ocanto + + + + + Home - Gustavo Ocanto + + + + + + + + + + + + + + + + diff --git a/src/partials/HeroPartial.vue b/src/partials/HeroPartial.vue index b27708dd..226f1e7e 100644 --- a/src/partials/HeroPartial.vue +++ b/src/partials/HeroPartial.vue @@ -9,10 +9,10 @@ leadership as a service. -

Writer, Speaker, Developer, Founder, and Leadership.

+

Writer, Speaker, Developer, AI Architect, Founder, and Leadership.

- I'm a full-stack Software Engineer leader with over two decades of building complex web systems and products, specialising in areas like e-commerce, banking, cross-payment - solutions, cyber security, and customer success. + I'm a full-stack Software Engineer leader with over two decades of of experience in building complex web systems and products, specialising in areas like e-commerce, banking, + cross-payment solutions, cyber security, and customer success.

diff --git a/src/support/seo.ts b/src/support/seo.ts new file mode 100644 index 00000000..6d8d545c --- /dev/null +++ b/src/support/seo.ts @@ -0,0 +1,183 @@ +import type { PostResponse } from '@api/response/posts-response.ts'; + +export const DEFAULT_SITE_URL = 'https://oullin.io' +export const SITE_NAME = 'Gustavo Ocanto'; +export const SITE_URL = + (import.meta.env?.VITE_SITE_URL as string | undefined) ?? + (typeof window !== 'undefined' ? window.location.origin : DEFAULT_SITE_URL); + + +type TwitterCard = 'summary' | 'summary_large_image' | 'app' | 'player'; + +interface SeoOptions { + title?: string; + description?: string; + keywords?: string; + image?: string; + url?: string; + siteName?: string; + type?: string; + themeColor?: string; + robots?: + | string + | { + index?: boolean; // default true + follow?: boolean; // default true + archive?: boolean; // default true + imageindex?: boolean; // default true + nocache?: boolean; // default false + noai?: boolean; + }; + twitter?: { + card?: TwitterCard; + site?: string; // e.g. @gocanto + creator?: string; // e.g. @gocanto + }; + jsonLd?: Record; +} + +export class Seo { + apply(options: SeoOptions): void { + const currentPath = window.location.pathname + window.location.search; + const url = options.url ?? new URL(currentPath, SITE_URL).toString(); + const image = options.image ? new URL(options.image, SITE_URL).toString() : undefined; + const title = options.title ? `${options.title} - ${SITE_NAME}` : SITE_NAME; + const description = options.description; + + document.title = title; + + // Generic meta + this.setMetaByName('description', description); + this.setMetaByName('keywords', options.keywords); + this.setMetaByName('robots', this.buildRobots(options.robots)); + this.setMetaByName('theme-color', options.themeColor ?? '#ffffff'); + this.setMetaByName('msapplication-TileColor', options.themeColor ?? '#ffffff'); + this.setMetaByName('application-name', title); + this.setMetaByName('apple-mobile-web-app-title', title); + + this.setLink('canonical', url); + + // Open Graph + this.setMetaByProperty('og:title', title); + this.setMetaByProperty('og:description', description); + this.setMetaByProperty('og:type', options.type ?? 'website'); + this.setMetaByProperty('og:url', url); + this.setMetaByProperty('og:image', image); + this.setMetaByProperty('og:site_name', options.siteName ?? SITE_NAME); + + // Twitter + const twitter = options.twitter ?? {}; + this.setMetaByName('twitter:card', twitter.card ?? 'summary_large_image'); + this.setMetaByName('twitter:site', twitter.site); + this.setMetaByName('twitter:creator', twitter.creator); + this.setMetaByName('twitter:title', title); + this.setMetaByName('twitter:description', description); + this.setMetaByName('twitter:image', image); + + // Structured data for AI and crawlers + this.setJsonLd(options.jsonLd); + } + + applyFromPost(post: PostResponse): void { + this.apply({ + title: post.title, + description: post.excerpt, + image: post.cover_image_url, + type: 'article', + url: new URL(`/posts/${post.slug}`, SITE_URL).toString(), + jsonLd: { + '@context': 'https://schema.org', + '@type': 'Article', + headline: post.title, + description: post.excerpt, + image: post.cover_image_url, + datePublished: post.published_at, + author: { + '@type': 'Person', + name: SITE_NAME, + }, + }, + }); + } + + private setMetaByName(name: string, content?: string): void { + if (!content) return; + + let element = document.head.querySelector(`meta[name="${name}"]`); + + if (!element) { + element = document.createElement('meta'); + element.setAttribute('name', name); + + document.head.appendChild(element); + } + + element.setAttribute('content', content); + } + + private setMetaByProperty(property: string, content?: string): void { + if (!content) return; + let element = document.head.querySelector(`meta[property="${property}"]`); + if (!element) { + element = document.createElement('meta'); + element.setAttribute('property', property); + document.head.appendChild(element); + } + element.setAttribute('content', content); + } + + private setLink(rel: string, href?: string): void { + if (!href) return; + let element = document.head.querySelector(`link[rel="${rel}"]`); + if (!element) { + element = document.createElement('link'); + element.setAttribute('rel', rel); + document.head.appendChild(element); + } + element.setAttribute('href', href); + } + + private setJsonLd(data?: Record): void { + const id = 'seo-jsonld'; + let script = document.getElementById(id) as HTMLScriptElement | null; + if (!data) { + if (script) script.remove(); + return; + } + const json = JSON.stringify(data); + if (!script) { + script = document.createElement('script'); + script.type = 'application/ld+json'; + script.id = id; + document.head.appendChild(script); + } + script.textContent = json; + } + + private buildRobots(robots?: SeoOptions['robots']): string | undefined { + if (!robots) return 'index,follow'; + if (typeof robots === 'string') return robots; + + const { + index = true, + follow = true, + archive = true, + imageindex = true, + nocache = false, + noai = false, + } = robots; + + const tokens: string[] = []; + tokens.push(index ? 'index' : 'noindex'); + tokens.push(follow ? 'follow' : 'nofollow'); + + if (!archive) tokens.push('noarchive'); + if (!imageindex) tokens.push('noimageindex'); + if (nocache) tokens.push('nocache'); + if (noai) tokens.push('noai', 'noimageai'); + + return tokens.join(','); + } +} + +export const seo = new Seo();