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();