Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/pages/AboutPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@

<script setup lang="ts">
import { computed, ref, onMounted } from 'vue';
import { seo, SITE_NAME } from '@/support/seo';
import { useSeo, SITE_NAME } from '@/support/seo';
import AboutPicture from '@images/profile/about.jpg';
import FooterPartial from '@partials/FooterPartial.vue';
import HeaderPartial from '@partials/HeaderPartial.vue';
Expand All @@ -107,7 +107,7 @@ const formattedNickname = computed((): string => {
return str.charAt(0).toUpperCase() + str.slice(1);
});

seo.apply({
useSeo({
title: 'About',
description: `${SITE_NAME} is an engineering leader who’s passionate about building reliable and smooth software.`,
image: AboutPicture,
Expand Down
4 changes: 2 additions & 2 deletions src/pages/HomePage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,13 @@ import { onMounted, ref } from 'vue';
import { useApiStore } from '@api/store.ts';
import { debugError } from '@api/http-error.ts';
import type { ProfileResponse } from '@api/response/index.ts';
import { seo, SITE_NAME } from '@/support/seo';
import { useSeo, SITE_NAME } from '@/support/seo';
import ogImage from '@images/profile/about.jpg';

const apiStore = useApiStore();
const profile = ref<ProfileResponse | null>(null);

seo.apply({
useSeo({
title: 'Home',
description: `${SITE_NAME} is a full-stack Software Engineer leader & architect with over two decades of experience in building complex web systems and products.`,
image: ogImage,
Expand Down
8 changes: 3 additions & 5 deletions src/pages/PostPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@
<script setup lang="ts">
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { seo } from '@/support/seo';
import { useSeoFromPost } from '@/support/seo';
import { useRoute } from 'vue-router';
import { useApiStore } from '@api/store.ts';
import { useDarkMode } from '@/dark-mode.ts';
Expand All @@ -136,6 +136,8 @@ const post = ref<PostResponse>();
const slug = ref<string>(route.params.slug as string);
const postContainer = ref<HTMLElement | null>(null);

useSeoFromPost(post);

marked.use({
breaks: true,
gfm: true,
Expand Down Expand Up @@ -196,10 +198,6 @@ onMounted(async () => {

try {
post.value = (await apiStore.getPost(slug.value)) as PostResponse;

if (post.value) {
seo.applyFromPost(post.value);
}
} catch (error) {
debugError(error);
}
Expand Down
4 changes: 2 additions & 2 deletions src/pages/ProjectsPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useApiStore } from '@api/store.ts';
import { seo, SITE_NAME } from '@/support/seo';
import { useSeo, SITE_NAME } from '@/support/seo';
import ogImage from '@images/profile/about.jpg';
import { debugError } from '@api/http-error.ts';
import FooterPartial from '@partials/FooterPartial.vue';
Expand All @@ -73,7 +73,7 @@ const apiStore = useApiStore();
const projects = ref<ProjectsResponse[]>([]);
const profile = ref<ProfileResponse | null>(null);

seo.apply({
useSeo({
title: 'Projects',
description: `Explore some of ${SITE_NAME} open source and client projects built to solve real engineering challenges.`,
image: ogImage,
Expand Down
4 changes: 2 additions & 2 deletions src/pages/ResumePage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import RecommendationPartial from '@partials/RecommendationPartial.vue';

import { ref, onMounted } from 'vue';
import { useApiStore } from '@api/store.ts';
import { seo, SITE_NAME } from '@/support/seo';
import { useSeo, SITE_NAME } from '@/support/seo';
import ogImage from '@images/profile/about.jpg';
import { debugError } from '@api/http-error.ts';
import type { ProfileResponse, EducationResponse, ExperienceResponse, RecommendationsResponse } from '@api/response/index.ts';
Expand All @@ -65,7 +65,7 @@ const education = ref<EducationResponse[] | null>(null);
const experience = ref<ExperienceResponse[] | null>(null);
const recommendations = ref<RecommendationsResponse[] | null>(null);

seo.apply({
useSeo({
title: 'Resume',
description: `Explore the experience, education, and recommendations of ${SITE_NAME}.`,
image: ogImage,
Expand Down
4 changes: 2 additions & 2 deletions src/pages/SubscribePage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,14 @@
</template>

<script setup lang="ts">
import { seo, SITE_NAME } from '@/support/seo';
import { useSeo, SITE_NAME } from '@/support/seo';
import ogImage from '@images/profile/about.jpg';
import HeaderPartial from '@partials/HeaderPartial.vue';
import FooterPartial from '@partials/FooterPartial.vue';
import SideNavPartial from '@partials/SideNavPartial.vue';
import WidgetSponsorPartial from '@partials/WidgetSponsorPartial.vue';

seo.apply({
useSeo({
title: 'Subscribe',
description: `Subscribe to ${SITE_NAME}'s newsletter to updates of articles and cool things he is working on.`,
image: ogImage,
Expand Down
114 changes: 71 additions & 43 deletions src/support/seo.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { computed, onBeforeUnmount, unref, watchEffect, type MaybeRefOrGetter } from 'vue';
import type { PostResponse } from '@api/response/posts-response.ts';

export const DEFAULT_SITE_URL = 'https://oullin.io'
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);

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';

Expand All @@ -21,23 +19,28 @@ interface SeoOptions {
robots?:
| string
| {
index?: boolean; // default true
follow?: boolean; // default true
archive?: boolean; // default true
imageindex?: boolean; // default true
nocache?: boolean; // default false
noai?: boolean;
};
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
site?: string; // e.g. @gocanto
creator?: string; // e.g. @gocanto
};
jsonLd?: Record<string, unknown>;
}

const hasDocument = typeof document !== 'undefined';
const hasWindow = typeof window !== 'undefined';

export class Seo {
apply(options: SeoOptions): void {
if (!hasDocument || !hasWindow) return;

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;
Expand Down Expand Up @@ -78,29 +81,8 @@ export class Seo {
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 (!hasDocument) return;
if (!content) return;

let element = document.head.querySelector<HTMLMetaElement>(`meta[name="${name}"]`);
Expand All @@ -116,6 +98,7 @@ export class Seo {
}

private setMetaByProperty(property: string, content?: string): void {
if (!hasDocument) return;
if (!content) return;
let element = document.head.querySelector<HTMLMetaElement>(`meta[property="${property}"]`);
if (!element) {
Expand All @@ -127,6 +110,7 @@ export class Seo {
}

private setLink(rel: string, href?: string): void {
if (!hasDocument) return;
if (!href) return;
let element = document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
if (!element) {
Expand All @@ -138,6 +122,7 @@ export class Seo {
}

private setJsonLd(data?: Record<string, unknown>): void {
if (!hasDocument) return;
const id = 'seo-jsonld';
let script = document.getElementById(id) as HTMLScriptElement | null;
if (!data) {
Expand All @@ -158,14 +143,7 @@ export class Seo {
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 { index = true, follow = true, archive = true, imageindex = true, nocache = false, noai = false } = robots;

const tokens: string[] = [];
tokens.push(index ? 'index' : 'noindex');
Expand All @@ -181,3 +159,53 @@ export class Seo {
}

export const seo = new Seo();

function resolveValue<T>(value: MaybeRefOrGetter<T>): T {
return typeof value === 'function' ? (value as () => T)() : unref(value);
}

export function useSeo(options: MaybeRefOrGetter<SeoOptions | null | undefined>): void {
if (!hasDocument || !hasWindow) return;

const stop = watchEffect(() => {
const resolved = resolveValue(options);

if (!resolved) return;

seo.apply(resolved);
});

onBeforeUnmount(() => {
stop();
});
}

export function useSeoFromPost(post: MaybeRefOrGetter<PostResponse | null | undefined>): void {
const seoOptions = computed<SeoOptions | undefined>(() => {
const value = resolveValue(post);

if (!value) return undefined;

return {
title: value.title,
description: value.excerpt,
image: value.cover_image_url,
type: 'article',
url: new URL(`/posts/${value.slug}`, SITE_URL).toString(),
jsonLd: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: value.title,
description: value.excerpt,
image: value.cover_image_url,
datePublished: value.published_at,
author: {
'@type': 'Person',
name: SITE_NAME,
},
},
} satisfies SeoOptions;
});

useSeo(seoOptions);
}