Tell search engines and link previews what each page is
- /sitemap.xml, from the API's /slugs plus the landing and listings: every address in every language, with hreflang and x-default. A 503 when the API is down, rather than a sitemap missing half the site. - /robots.txt, naming the sitemap on whatever domain this is served on. - Slugs renamed in the panel now redirect. The API has always kept the old address the moment one changed; middleware/redirects.ts answers it with a 301, from a list kept for a minute and through outages. - og:url, og:image (the record's own picture, the logo otherwise) and schema.org JSON-LD: Organization on the landing; WebPage, CreativeWork, Person or Service elsewhere. Shared through core/head. - The proxy-derived origin moves to core/origin.ts, used by the page shell, the landing and both server routes. In development only /api/* reaches Nitro, so the two files 404 under `npm start`; they were checked against a production build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
59
landing/src/server/middleware/redirects.ts
Normal file
59
landing/src/server/middleware/redirects.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Answers an address the panel has retired with a redirect to where the
|
||||
* record lives now, instead of the 404 it would otherwise be.
|
||||
*
|
||||
* The list comes from the API whole — there are never many — and is kept for
|
||||
* a minute, so a page view costs no extra request. If the API does not answer,
|
||||
* the last list that arrived is kept: an outage should not turn every renamed
|
||||
* link back into a 404.
|
||||
*/
|
||||
import { defineEventHandler, getRequestURL, sendRedirect } from 'h3';
|
||||
|
||||
import { pireonApi, type Redirect } from '../../app/core/api';
|
||||
import { findRedirect } from '../../app/core/redirects';
|
||||
|
||||
const TTL_MS = 60_000;
|
||||
|
||||
let cached: Redirect[] = [];
|
||||
let loadedAt = 0;
|
||||
let pending: Promise<void> | null = null;
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
const { data, error } = await pireonApi.redirects();
|
||||
|
||||
if (!error && data) {
|
||||
cached = data;
|
||||
}
|
||||
|
||||
loadedAt = Date.now();
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
if (event.method !== 'GET' && event.method !== 'HEAD') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { pathname } = getRequestURL(event);
|
||||
|
||||
// The app's own endpoints and files are never renamed in the panel.
|
||||
if (
|
||||
/^\/(api|_analog|assets|@|src|node_modules)\//.test(pathname) ||
|
||||
/\.[a-z0-9]+$/i.test(pathname)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Date.now() - loadedAt > TTL_MS) {
|
||||
pending ??= refresh().finally(() => {
|
||||
pending = null;
|
||||
});
|
||||
|
||||
await pending;
|
||||
}
|
||||
|
||||
const redirect = findRedirect(pathname, cached);
|
||||
|
||||
if (redirect) {
|
||||
return sendRedirect(event, redirect.to, redirect.status);
|
||||
}
|
||||
});
|
||||
21
landing/src/server/routes/robots.txt.get.ts
Normal file
21
landing/src/server/routes/robots.txt.get.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Everything is crawlable, and the sitemap says where everything is. Absolute,
|
||||
* because robots.txt requires it, from whichever domain this is served on.
|
||||
*/
|
||||
import { defineEventHandler, getRequestHeaders, setResponseHeader } from 'h3';
|
||||
|
||||
import { originFromHeaders } from '../../app/core/origin';
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const origin = originFromHeaders(getRequestHeaders(event)) ?? '';
|
||||
|
||||
setResponseHeader(event, 'Content-Type', 'text/plain; charset=utf-8');
|
||||
|
||||
return [
|
||||
'User-agent: *',
|
||||
'Allow: /',
|
||||
'',
|
||||
`Sitemap: ${origin}/sitemap.xml`,
|
||||
'',
|
||||
].join('\n');
|
||||
});
|
||||
34
landing/src/server/routes/sitemap.xml.get.ts
Normal file
34
landing/src/server/routes/sitemap.xml.get.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* `/sitemap.xml`, from the API's `/slugs` — one request for every published
|
||||
* address, instead of paging through each collection.
|
||||
*
|
||||
* Cached like any page: a record published a minute ago can wait a minute to
|
||||
* be listed. If the API is down the answer is a 503 rather than a sitemap
|
||||
* with half the site missing from it, which a crawler would believe.
|
||||
*/
|
||||
import {
|
||||
defineEventHandler,
|
||||
getRequestHeaders,
|
||||
setResponseHeader,
|
||||
setResponseStatus,
|
||||
} from 'h3';
|
||||
|
||||
import { pireonApi } from '../../app/core/api';
|
||||
import { originFromHeaders } from '../../app/core/origin';
|
||||
import { buildSitemap } from '../../app/core/sitemap';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const origin = originFromHeaders(getRequestHeaders(event)) ?? '';
|
||||
const { data } = await pireonApi.slugs();
|
||||
|
||||
if (!data) {
|
||||
setResponseStatus(event, 503);
|
||||
setResponseHeader(event, 'Cache-Control', 'no-store');
|
||||
|
||||
return 'Sitemap temporarily unavailable';
|
||||
}
|
||||
|
||||
setResponseHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
|
||||
|
||||
return buildSitemap(origin, data);
|
||||
});
|
||||
Reference in New Issue
Block a user