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
25 changes: 25 additions & 0 deletions docs/guide/pages-routing/head.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,31 @@ When multiple layers provide head data, they merge with this precedence:
| `htmlAttrs` | Shallow merge, page wins conflicts. |
| `bodyAttrs` | Shallow merge, page wins conflicts. |

## Absolute crawler URLs

Social crawlers and link previews only read an absolute URL. These meta tags are resolved against the request URL when their `content` is a relative path, so you can write them without knowing the origin:

- `og:image`, `og:image:url`, `og:image:secure_url`
- `og:video`, `og:video:url`, `og:video:secure_url`
- `og:audio`, `og:audio:url`, `og:audio:secure_url`
- `og:url`
- `twitter:image`

```ts
// void.config.ts
import { defineConfig } from 'void/config';

export default defineConfig({
head: {
meta: [{ property: 'og:image', content: '/og-image.jpg' }], // [!code highlight]
},
});
```

A request to `https://example.com/posts/hello` renders `<meta property="og:image" content="https://example.com/og-image.jpg">`. Values that are already absolute, and every other tag, are left as written. Config, middleware and page values resolve the same way, and the client receives the same absolute value on navigation.

Prerendered pages keep the value relative, because the build does not know the site's origin.

## HeadDescriptor

```ts
Expand Down
65 changes: 65 additions & 0 deletions packages/void/src/pages/head.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,71 @@ function serializeAttrs(attrs: Record<string, string | undefined>): string {
.join(' ');
}

/** Meta tags that only work as an absolute URL, so crawlers can read them. */
const ABSOLUTE_URL_META = new Set([
'og:image',
'og:image:url',
'og:image:secure_url',
'og:video',
'og:video:url',
'og:video:secure_url',
'og:audio',
'og:audio:url',
'og:audio:secure_url',
'og:url',
'twitter:image',
]);

/** Any scheme, or a protocol-relative `//host/path`, is already absolute. */
function isAbsoluteUrl(value: string): boolean {
return value.startsWith('//') || /^[a-z][a-z\d+.-]*:/i.test(value);
}

/**
* Resolve relative values of the meta tags that must be absolute against the
* request URL. Config, middleware and page layers all end up here after
* merging, so a root-relative `og:image` becomes a URL social crawlers accept
* without the app having to know its own origin.
*
* `requestUrl` is undefined while prerendering, which runs on a placeholder
* origin: baking that origin into the built HTML would be worse than the
* relative value, so the tags stay as written.
*/
export function absolutizeHead(
head: HeadDescriptor | undefined,
requestUrl: string | undefined,
): HeadDescriptor | undefined {
const entries = head?.meta;
if (!entries?.length || requestUrl === undefined) {
return head;
}
let base: URL;
try {
base = new URL(requestUrl);
} catch {
// A request URL is always valid; keep the values as written if it is not.
return head;
}
let meta: NonNullable<HeadDescriptor['meta']> | undefined;
for (const [index, entry] of entries.entries()) {
const key = entry.property ?? entry.name;
const content = entry.content;
if (!key || !content || !ABSOLUTE_URL_META.has(key) || isAbsoluteUrl(content)) {
continue;
}
let resolved: string;
try {
resolved = new URL(content, base).href;
} catch {
// Leave a malformed value exactly as the app wrote it.
continue;
}
meta ??= [...entries];
meta[index] = { ...entry, content: resolved };
}
return meta ? { ...head!, meta } : head;
}

/** Serialize a HeadDescriptor to HTML tags for injection into `<head>`. */
export function renderHeadToString(head: HeadDescriptor): string {
const parts: Array<string> = [];
Expand Down
29 changes: 26 additions & 3 deletions packages/void/src/pages/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { isConvertedResponse } from '../runtime/response.ts';
import { hasActionKey, searchWithoutActionKey } from './action-key.ts';
import { DEFERRED, separateDeferred, type ResolvedDeferredGroup } from './defer.ts';
import {
absolutizeHead,
resolveHead,
renderBodyAttrs,
renderHeadToString,
Expand Down Expand Up @@ -445,7 +446,12 @@ async function renderPageFailure(
let head: HeadDescriptor | undefined;
// Invalid shared/head input is not repairable by trying another component.
try {
head = resolveHead(configHead, c.get('headDefaults') as HeadConfig | undefined, undefined);
head = resolveRequestHead(
c,
configHead,
c.get('headDefaults') as HeadConfig | undefined,
undefined,
);
serializePageData({ shared, head });
} catch (cause) {
throw new Error('Unable to render Pages error boundary', { cause });
Expand Down Expand Up @@ -691,6 +697,23 @@ export async function handlePageGet(
);
}

/**
* Resolve the head for this request and make the crawler-only tags absolute.
* A prerender renders on a placeholder origin, so it keeps relative values.
*/
function resolveRequestHead(
c: CloudContext,
configHead: HeadConfig | undefined,
middlewareHead: HeadConfig | undefined,
pageHead: HeadDescriptor | undefined,
): HeadDescriptor | undefined {
const head = resolveHead(configHead, middlewareHead, pageHead);
return absolutizeHead(
head,
c.env?.__VOID_PRERENDER === '1' ? undefined : (c.originalUrl?.()?.href ?? c.req.url),
);
}

async function runPageGet(
handler: PageHandler,
headFn: PageHeadHandler,
Expand Down Expand Up @@ -719,7 +742,7 @@ async function runPageGet(
const shared = (c.get('shared') as Record<string, unknown> | undefined) ?? {};
const pageHead = headFn ? headFn(c, props) : undefined;
const middlewareHead = c.get('headDefaults') as HeadConfig | undefined;
const head = resolveHead(configHead, middlewareHead, pageHead);
const head = resolveRequestHead(c, configHead, middlewareHead, pageHead);
if (renderMode !== 'island') {
varyPagesResponse(c);
}
Expand Down Expand Up @@ -920,7 +943,7 @@ async function runPageMutation(
const shared = (c.get('shared') as Record<string, unknown> | undefined) ?? {};
const middlewareHead = c.get('headDefaults') as HeadConfig | undefined;
const pageHead = headFn ? headFn(c, props) : undefined;
const head = resolveHead(configHead, middlewareHead, pageHead);
const head = resolveRequestHead(c, configHead, middlewareHead, pageHead);
return {
component: componentId,
props,
Expand Down
65 changes: 65 additions & 0 deletions packages/void/test/unit/pages-head.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vite-plus/test';
import {
absolutizeHead,
resolveHead,
renderHeadToString,
renderHtmlAttrs,
Expand Down Expand Up @@ -156,6 +157,70 @@ describe('resolveHead', () => {
});
});

describe('absolutizeHead', () => {
const page = (meta: NonNullable<HeadDescriptor['meta']>): HeadDescriptor => ({ meta });

it('resolves a root-relative crawler URL against the request', () => {
const head = page([{ property: 'og:image', content: '/og-image.jpg' }]);
expect(absolutizeHead(head, 'https://app.test/docs/intro')?.meta).toEqual([
{ property: 'og:image', content: 'https://app.test/og-image.jpg' },
]);
});

it('resolves every meta tag that must be absolute', () => {
const keys = [
'og:image',
'og:image:url',
'og:image:secure_url',
'og:video',
'og:video:url',
'og:video:secure_url',
'og:audio',
'og:audio:url',
'og:audio:secure_url',
'og:url',
];
const head = page(keys.map((property) => ({ property, content: '/shared' })));
for (const entry of absolutizeHead(head, 'https://app.test/blog/post')!.meta!) {
expect(entry.content).toBe('https://app.test/shared');
}
});

it('resolves twitter:image by name', () => {
const head = page([{ name: 'twitter:image', content: '/card.png' }]);
expect(absolutizeHead(head, 'https://app.test/')?.meta![0]?.content).toBe(
'https://app.test/card.png',
);
});

it('keeps absolute, protocol-relative and other values as written', () => {
const meta: NonNullable<HeadDescriptor['meta']> = [
{ property: 'og:image', content: 'https://cdn.test/a.png' },
{ property: 'og:url', content: '//app.test/b' },
{ name: 'description', content: '/not-a-url' },
{ property: 'og:title', content: '/still-a-title' },
];
const head = page(meta);
expect(absolutizeHead(head, 'https://app.test/')).toBe(head);
});

it('returns the input when there is nothing to resolve', () => {
expect(absolutizeHead(undefined, 'https://app.test/')).toBeUndefined();
const head: HeadDescriptor = { title: 'Home' };
expect(absolutizeHead(head, 'https://app.test/')).toBe(head);
});

it('keeps values relative without a request URL, such as during a prerender', () => {
const head = page([{ property: 'og:image', content: '/og-image.jpg' }]);
expect(absolutizeHead(head, undefined)).toBe(head);
});

it('leaves a malformed value exactly as the app wrote it', () => {
const head = page([{ property: 'og:image', content: 'http://' }]);
expect(absolutizeHead(head, 'https://app.test/')?.meta).toEqual(head.meta);
});
});

describe('renderHeadToString', () => {
it('renders title', () => {
expect(renderHeadToString({ title: 'Hello' })).toBe('<title>Hello</title>');
Expand Down
17 changes: 13 additions & 4 deletions packages/void/test/unit/routing-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1177,7 +1177,7 @@ describe('typed Pages and SSR execution', () => {
['middleware', 'POST', '/app/account?tab=1', '/maintenance?save&tab=1', '/app/account?tab=1'],
['rule', 'POST', '/app/account?tab=1', '/maintenance?save&tab=1', '/app/account?tab=1'],
])(
'reports the requested URL after a %s rewrite of %s %s to %s',
'preserves public page and head URLs after a %s rewrite of %s %s to %s',
async (kind, method, path, destination, url) => {
const app = new Hono();
const runtime = createRoutingRuntime(app, { nodeTarget: true });
Expand All @@ -1190,11 +1190,15 @@ describe('typed Pages and SSR execution', () => {
runtime.routingMiddleware([{ source: '/app/*', destination, status: 200, force: true }]),
);
}
app.get('/maintenance', createPageGetHandler(pageOptions));
const options = {
...pageOptions,
headConfig: { meta: [{ property: 'og:image', content: 'preview.png' }] },
};
app.get('/maintenance', createPageGetHandler(options));
app.post(
'/maintenance',
createPageMutationHandler({
...pageOptions,
...options,
load: async () => ({ actions: { save: () => undefined } }),
namedActions: true,
}),
Expand All @@ -1205,7 +1209,12 @@ describe('typed Pages and SSR execution', () => {
headers: { 'x-voidpages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION },
}),
);
expect((decodePageData(await response.json()) as { url: string }).url).toBe(url);
expect(decodePageData(await response.json())).toMatchObject({
url,
head: {
meta: [{ property: 'og:image', content: 'https://app.test/app/preview.png' }],
},
});
},
);
it('executes the real page loader and protocol', async () => {
Expand Down
Loading