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:
pigui
2026-09-29 15:21:59 +02:00
parent 399b8d5450
commit 86b7485b32
17 changed files with 639 additions and 22 deletions

View 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);
}
});