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: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,9 @@ GOOGLE_TAG_MANAGER_ID=GTM-PTLT3GH
POSTHOG_API_HOST=https://directus.io/ingest
POSTHOG_API_KEY=phc_secret_key_here
NUXT_PUBLIC_SITE_URL=https://directus.com
NUXT_PUBLIC_OG_BASE_URL=https://og.directus.com
# Required outside local OG worker dev.
# NUXT_OG_SIGNING_SECRET=shared_secret_from_website_og_worker
# OG_SIGNING_SECRET=shared_secret_from_website_og_worker
# Optional. Fine-grained PAT, public repos read-only. Required for code search and raises GitHub raw rate limits.
# GITHUB_TOKEN=github_pat_...
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Reusable content fragments live in `/content/_partials/` and are included via th

### Modules & Integrations

Nuxt modules: `@nuxt/ui-pro`, `@nuxt/content`, `@nuxtjs/seo`, `@nuxtjs/algolia` (conditional on env vars), `@vueuse/nuxt`, `@nuxt/scripts`. Custom PostHog module in `/modules/posthog/`.
Nuxt modules: `@nuxt/ui-pro`, `@nuxt/content`, `@nuxtjs/robots`, `@nuxtjs/sitemap`, `@nuxtjs/algolia` (conditional on env vars), `@vueuse/nuxt`, `@nuxt/scripts`. Custom PostHog module in `/modules/posthog/`.

## Code Style

Expand Down
4 changes: 1 addition & 3 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ const { data: navigation } = await useAsyncData('content-navigation', () => quer

provide('navigation', navigation as Ref<ContentNavigationItem[]>);

defineOgImage('Default', {
title: 'Directus Docs',
});
await useDocsOgImage();
</script>

<template>
Expand Down
92 changes: 0 additions & 92 deletions app/components/OgImage/Default.takumi.vue

This file was deleted.

51 changes: 51 additions & 0 deletions app/composables/useDocsOgImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
const DEFAULT_TITLE = 'Directus Docs';
const DEFAULT_DESCRIPTION = 'Explore our resources and powerful data engine to build your projects confidently.';
const MAX_OG_PARAM_BYTES = 512;
const textEncoder = new TextEncoder();

interface DocsOgImageInput {
title?: string;
description?: string;
breadcrumb?: string[];
}

function truncateOgParam(value?: string | null) {
if (!value) return value;
if (textEncoder.encode(value).byteLength <= MAX_OG_PARAM_BYTES) return value;

let output = '';

for (const char of value) {
if (textEncoder.encode(`${output}${char}...`).byteLength > MAX_OG_PARAM_BYTES) break;
output += char;
}

return `${output}...`;
}

export async function useDocsOgImage(input: DocsOgImageInput = {}) {
const title = input.title ?? DEFAULT_TITLE;
const description = input.description ?? DEFAULT_DESCRIPTION;
const breadcrumb = input.breadcrumb?.filter(Boolean).join(' > ');
const ogImage = ref<string>();

useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
ogImage: computed(() => ogImage.value),
twitterCard: 'summary_large_image',
});

ogImage.value = await useOgImageUrl({
template: 'docs',
params: {
title: truncateOgParam(title),
description: truncateOgParam(description),
breadcrumb: truncateOgParam(breadcrumb),
},
});

return ogImage.value;
}
37 changes: 37 additions & 0 deletions app/composables/useOgImageUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { OgImageInput } from '~/utils/og-sign';
import { OG_IMAGE_VERSION, getOgImageUrl, signOgImage } from '~/utils/og-sign';

const warnedMissingSecretHosts = new Set<string>();

export async function useOgImageUrl(input: OgImageInput) {
const key = `og-image:${JSON.stringify(input)}`;
const state = useState<string | undefined>(key);

if (import.meta.client) return state.value;
if (state.value) return state.value;

const config = useRuntimeConfig();
const secret = config.ogSigningSecret;
const baseUrl = config.public.ogBaseUrl;
const hostname = new URL(baseUrl).hostname;

if (!secret && hostname !== 'localhost') {
if (!warnedMissingSecretHosts.has(hostname)) {
console.warn(`Missing OG_SIGNING_SECRET or NUXT_OG_SIGNING_SECRET for ${baseUrl}`);
warnedMissingSecretHosts.add(hostname);
}

return undefined;
}

if (!secret) {
state.value = getOgImageUrl(baseUrl, input.template, { v: OG_IMAGE_VERSION, ...input.params });

return state.value;
}

const { params, signature } = await signOgImage(input, secret);
state.value = getOgImageUrl(baseUrl, input.template, params, signature);

return state.value;
}
2 changes: 1 addition & 1 deletion app/pages/[...slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const ogBreadcrumb = computed(() =>
.filter((title): title is string => Boolean(title)),
);

defineOgImage('Default', {
await useDocsOgImage({
title: page.value?.title ?? 'Directus Docs',
description: page.value?.description ?? '',
breadcrumb: ogBreadcrumb.value,
Expand Down
2 changes: 1 addition & 1 deletion app/pages/frameworks/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const frameworkCards = computed<FrameworkCard[]>(() => {
});
});

useSeoMeta({
await useDocsOgImage({
title: 'Frameworks',
description: 'Find Directus guides for your frontend framework, application stack, or platform.',
});
Expand Down
5 changes: 2 additions & 3 deletions app/pages/tutorials/[category]/[...slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,11 @@ const frameworkChips = computed(() => {
.filter(c => c !== null);
});

defineOgImage('Default', {
await useDocsOgImage({
title: page.value?.title ?? 'Directus Docs',
description: page.value?.description ?? '',
breadcrumb: breadcrumb.value
.map(item => item.label)
.filter((label): label is string => Boolean(label)),
.flatMap(item => ('label' in item && item.label ? [item.label] : [])),
});
</script>

Expand Down
59 changes: 59 additions & 0 deletions app/utils/og-sign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { canonicalPayloadFromEntries } from '../../lib/og-signing.mjs';

export type OgTemplate = 'default' | 'docs';

export type OgParams = Record<string, string | number | boolean | null | undefined>;

export interface OgImageInput {
template: OgTemplate;
params: OgParams;
}

export const OG_IMAGE_VERSION = '1';

const textEncoder = new TextEncoder();

function toHex(buffer: ArrayBuffer) {
return [...new Uint8Array(buffer)].map(byte => byte.toString(16).padStart(2, '0')).join('');
}

export async function signOgImage(input: OgImageInput, secret: string) {
const params = {
v: OG_IMAGE_VERSION,
...input.params,
};

// The worker signs v both as the header version and as a query param.
const canonical = canonicalPayloadFromEntries(input.template, String(params.v), Object.entries(params));

const key = await crypto.subtle.importKey(
'raw',
textEncoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);

const signature = await crypto.subtle.sign('HMAC', key, textEncoder.encode(canonical));

return {
params,
signature: toHex(signature),
};
}

export function getOgImageUrl(baseUrl: string, template: OgTemplate, params: OgParams, signature?: string) {
const url = new URL(`/template/${template}`, baseUrl);

for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
}

if (signature) {
url.searchParams.set('sig', signature);
}

return url.toString();
}
17 changes: 17 additions & 0 deletions lib/og-signing.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export interface CanonicalQueryOptions {
omitSignature?: boolean;
}

export function encodeComponent(value: unknown): string;

export function canonicalQueryFromEntries(
entries: Iterable<readonly [string, unknown]>,
options?: CanonicalQueryOptions,
): string;

export function canonicalPayloadFromEntries(
template: string,
version: string,
entries: Iterable<readonly [string, unknown]>,
options?: CanonicalQueryOptions,
): string;
22 changes: 22 additions & 0 deletions lib/og-signing.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export function encodeComponent(value) {
return encodeURIComponent(String(value).normalize('NFC')).replace(/[!'()*]/g, (char) =>
`%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
}

export function canonicalQueryFromEntries(entries, options = {}) {
return [...entries]
.filter(([key, value]) => value !== undefined && value !== null && (!options.omitSignature || key !== 'sig'))
.map(([key, value]) => [encodeComponent(key), encodeComponent(value)])
.sort(([a], [b]) => {
if (a < b) return -1;
if (a > b) return 1;
return 0;
})
.map(([key, value]) => `${key}=${value}`)
.join('&');
}

export function canonicalPayloadFromEntries(template, version, entries, options = {}) {
return `v=${version}\ntemplate=${template}\n${canonicalQueryFromEntries(entries, options)}`;
}
Loading
Loading