diff --git a/docs/guide/pages-routing/head.md b/docs/guide/pages-routing/head.md index 2583de85d..ca3c9f35d 100644 --- a/docs/guide/pages-routing/head.md +++ b/docs/guide/pages-routing/head.md @@ -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 ``. 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 diff --git a/packages/void/src/pages/head.ts b/packages/void/src/pages/head.ts index 80c9f06d3..389752337 100644 --- a/packages/void/src/pages/head.ts +++ b/packages/void/src/pages/head.ts @@ -124,6 +124,71 @@ function serializeAttrs(attrs: Record): 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 | 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 ``. */ export function renderHeadToString(head: HeadDescriptor): string { const parts: Array = []; diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts index 0f90a3deb..58b206a5f 100644 --- a/packages/void/src/pages/protocol.ts +++ b/packages/void/src/pages/protocol.ts @@ -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, @@ -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 }); @@ -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, @@ -719,7 +742,7 @@ async function runPageGet( const shared = (c.get('shared') as Record | 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); } @@ -920,7 +943,7 @@ async function runPageMutation( const shared = (c.get('shared') as Record | 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, diff --git a/packages/void/test/unit/pages-head.test.ts b/packages/void/test/unit/pages-head.test.ts index 4624b90e2..8a6021269 100644 --- a/packages/void/test/unit/pages-head.test.ts +++ b/packages/void/test/unit/pages-head.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vite-plus/test'; import { + absolutizeHead, resolveHead, renderHeadToString, renderHtmlAttrs, @@ -156,6 +157,70 @@ describe('resolveHead', () => { }); }); +describe('absolutizeHead', () => { + const page = (meta: NonNullable): 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 = [ + { 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('Hello'); diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index a63ab86d4..b4f79cbdb 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -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 }); @@ -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, }), @@ -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 () => {