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:
@@ -185,7 +185,28 @@ async function list<T>(path: string, options: GetOptions = {}): Promise<ApiResul
|
||||
return { ...result, data: result.data ?? [] };
|
||||
}
|
||||
|
||||
/** One published record in `/slugs`: its address in every language it has. */
|
||||
export interface SlugEntry {
|
||||
id: number;
|
||||
updated_at?: string;
|
||||
alternates: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Redirect {
|
||||
from: string;
|
||||
to: string;
|
||||
status: 301 | 302;
|
||||
/** Null: applies whatever language the visitor is browsing in. */
|
||||
locale?: string | null;
|
||||
}
|
||||
|
||||
export const pireonApi = {
|
||||
/** Every published address, grouped by type. What the sitemap is built from. */
|
||||
slugs: () => get<Record<string, SlugEntry[]>>('/slugs'),
|
||||
|
||||
/** Addresses that used to work and where they go now. Whole: there are never many. */
|
||||
redirects: () => list<Redirect>('/redirects', { timeout: 3000 }),
|
||||
|
||||
site: (lang: Locale) => get<SitePayload>('/site', { lang }),
|
||||
|
||||
services: (lang: Locale, query?: GetOptions['query']) =>
|
||||
|
||||
42
landing/src/app/core/head.spec.ts
Normal file
42
landing/src/app/core/head.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { absoluteUrl, setStructuredData } from './head';
|
||||
|
||||
describe('absoluteUrl', () => {
|
||||
it('puts a path on the origin and leaves a URL alone', () => {
|
||||
expect(absoluteUrl('https://pireon.dev', '/logo.png')).toBe(
|
||||
'https://pireon.dev/logo.png',
|
||||
);
|
||||
expect(absoluteUrl('https://pireon.dev', 'https://cdn.example/a.png')).toBe(
|
||||
'https://cdn.example/a.png',
|
||||
);
|
||||
expect(absoluteUrl(null, '/logo.png')).toBe('/logo.png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setStructuredData', () => {
|
||||
it("writes one script, replacing the previous page's", () => {
|
||||
setStructuredData(document, { '@type': 'Person', name: 'Ada' });
|
||||
setStructuredData(document, { '@type': 'Service', name: 'Formación' });
|
||||
|
||||
const scripts = document.head.querySelectorAll(
|
||||
'script[type="application/ld+json"]',
|
||||
);
|
||||
|
||||
expect(scripts).toHaveLength(1);
|
||||
expect(JSON.parse(scripts[0].textContent ?? '')).toEqual({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Service',
|
||||
name: 'Formación',
|
||||
});
|
||||
});
|
||||
|
||||
it('cannot be closed early by what it carries', () => {
|
||||
setStructuredData(document, { name: '</script><script>alert(1)</script>' });
|
||||
|
||||
expect(
|
||||
document.head.querySelector('script[type="application/ld+json"]')
|
||||
?.textContent,
|
||||
).not.toContain('</script>');
|
||||
});
|
||||
});
|
||||
56
landing/src/app/core/head.ts
Normal file
56
landing/src/app/core/head.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The parts of `<head>` that describe a page to machines rather than people:
|
||||
* the preview a chat app or social network draws, and schema.org data a
|
||||
* search engine reads. Shared by the landing and every page reached by its
|
||||
* address, so both say it the same way.
|
||||
*/
|
||||
import type { Meta } from '@angular/platform-browser';
|
||||
|
||||
const JSON_LD_ID = 'page-structured-data';
|
||||
|
||||
/** Relative paths become absolute on `origin`; absolute URLs pass through. */
|
||||
export function absoluteUrl(origin: string | null, href: string): string {
|
||||
return /^https?:\/\//.test(href) || !origin ? href : `${origin}${href}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* `og:image` and the card size that goes with it. Without an image of the
|
||||
* record's own, the site's logo, as a small card rather than a stretched one.
|
||||
*/
|
||||
export function setShareImage(
|
||||
meta: Meta,
|
||||
origin: string | null,
|
||||
image?: string | null,
|
||||
): void {
|
||||
meta.updateTag({
|
||||
property: 'og:image',
|
||||
content: absoluteUrl(origin, image || '/logo.png'),
|
||||
});
|
||||
meta.updateTag({
|
||||
name: 'twitter:card',
|
||||
content: image ? 'summary_large_image' : 'summary',
|
||||
});
|
||||
}
|
||||
|
||||
/** One `<script type="application/ld+json">`, replaced rather than stacked. */
|
||||
export function setStructuredData(
|
||||
document: Document,
|
||||
data: Record<string, unknown> | null,
|
||||
): void {
|
||||
document.getElementById(JSON_LD_ID)?.remove();
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
|
||||
script.id = JSON_LD_ID;
|
||||
script.setAttribute('type', 'application/ld+json');
|
||||
// `<` escaped so no string in the data can close the script element.
|
||||
script.textContent = JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
...data,
|
||||
}).replace(/</g, '\\u003c');
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
38
landing/src/app/core/origin.spec.ts
Normal file
38
landing/src/app/core/origin.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { originFromHeaders } from './origin';
|
||||
|
||||
describe('originFromHeaders', () => {
|
||||
it('believes the proxy over the host it connected to', () => {
|
||||
expect(
|
||||
originFromHeaders({
|
||||
host: 'landing:3000',
|
||||
'x-forwarded-host': 'pireon.dev',
|
||||
'x-forwarded-proto': 'https',
|
||||
}),
|
||||
).toBe('https://pireon.dev');
|
||||
});
|
||||
|
||||
it('takes the first hop of a list', () => {
|
||||
expect(
|
||||
originFromHeaders({
|
||||
'x-forwarded-host': 'pireon.dev, internal',
|
||||
'x-forwarded-proto': 'https,http',
|
||||
}),
|
||||
).toBe('https://pireon.dev');
|
||||
});
|
||||
|
||||
it('assumes plain HTTP only on a developer machine', () => {
|
||||
expect(originFromHeaders({ host: 'localhost:4200' })).toBe(
|
||||
'http://localhost:4200',
|
||||
);
|
||||
expect(originFromHeaders({ host: 'pireon.dev' })).toBe(
|
||||
'https://pireon.dev',
|
||||
);
|
||||
});
|
||||
|
||||
it('has nothing to say without headers', () => {
|
||||
expect(originFromHeaders(undefined)).toBeNull();
|
||||
expect(originFromHeaders({})).toBeNull();
|
||||
});
|
||||
});
|
||||
27
landing/src/app/core/origin.ts
Normal file
27
landing/src/app/core/origin.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The origin a visitor used, as the reverse proxy in front of this app reports
|
||||
* it. Absolute URLs — `canonical`, `hreflang`, the sitemap, `og:image` — need
|
||||
* one, and this app has no configured address of its own: it answers on
|
||||
* whichever domain the proxy sends it.
|
||||
*/
|
||||
export type HeaderBag = Record<string, string | string[] | undefined>;
|
||||
|
||||
export function originFromHeaders(
|
||||
headers: HeaderBag | undefined,
|
||||
): string | null {
|
||||
if (!headers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const first = (value: string | string[] | undefined) =>
|
||||
(Array.isArray(value) ? value[0] : value)?.split(',')[0]?.trim();
|
||||
|
||||
const host = first(headers['x-forwarded-host']) ?? first(headers['host']);
|
||||
// Behind the proxy the header is always there; without one this is a
|
||||
// developer's own machine, which does not speak TLS.
|
||||
const protocol =
|
||||
first(headers['x-forwarded-proto']) ??
|
||||
(/^(localhost|127\.)/.test(host ?? '') ? 'http' : 'https');
|
||||
|
||||
return host ? `${protocol}://${host}` : null;
|
||||
}
|
||||
40
landing/src/app/core/redirects.spec.ts
Normal file
40
landing/src/app/core/redirects.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { findRedirect } from './redirects';
|
||||
|
||||
const redirects = [
|
||||
{ from: '/proyectos/viejo', to: '/proyectos/nuevo', status: 301 as const },
|
||||
{
|
||||
from: '/ca/projectes/antic/',
|
||||
to: '/ca/projectes/nou',
|
||||
status: 302 as const,
|
||||
},
|
||||
{ from: '/bucle', to: '/bucle/', status: 301 as const },
|
||||
];
|
||||
|
||||
describe('findRedirect', () => {
|
||||
it('sends an old address to the new one, with its status', () => {
|
||||
expect(findRedirect('/proyectos/viejo', redirects)).toEqual({
|
||||
to: '/proyectos/nuevo',
|
||||
status: 301,
|
||||
});
|
||||
expect(findRedirect('/ca/projectes/antic', redirects)).toEqual({
|
||||
to: '/ca/projectes/nou',
|
||||
status: 302,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a trailing slash as the same address', () => {
|
||||
expect(findRedirect('/proyectos/viejo/', redirects)?.to).toBe(
|
||||
'/proyectos/nuevo',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves every other address alone', () => {
|
||||
expect(findRedirect('/proyectos/nuevo', redirects)).toBeNull();
|
||||
});
|
||||
|
||||
it('never answers with a loop', () => {
|
||||
expect(findRedirect('/bucle', redirects)).toBeNull();
|
||||
});
|
||||
});
|
||||
28
landing/src/app/core/redirects.ts
Normal file
28
landing/src/app/core/redirects.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Which redirect, if any, an address answers with.
|
||||
*
|
||||
* The panel writes one the moment somebody renames a slug — the only moment
|
||||
* the old address is still known — so a link shared before the rename lands
|
||||
* on the record instead of a 404.
|
||||
*/
|
||||
import type { Redirect } from './api';
|
||||
|
||||
/** Trailing slashes are the same address; everything else must match exactly. */
|
||||
function normalise(path: string): string {
|
||||
return path.length > 1 ? path.replace(/\/+$/, '') : path;
|
||||
}
|
||||
|
||||
export function findRedirect(
|
||||
pathname: string,
|
||||
redirects: Redirect[],
|
||||
): Pick<Redirect, 'to' | 'status'> | null {
|
||||
const path = normalise(pathname);
|
||||
const match = redirects.find((redirect) => normalise(redirect.from) === path);
|
||||
|
||||
// One pointing at itself would loop forever in the visitor's browser.
|
||||
if (!match || normalise(match.to) === path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { to: match.to, status: match.status === 302 ? 302 : 301 };
|
||||
}
|
||||
49
landing/src/app/core/sitemap.spec.ts
Normal file
49
landing/src/app/core/sitemap.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildSitemap } from './sitemap';
|
||||
|
||||
const xml = buildSitemap('https://pireon.dev', {
|
||||
pages: [
|
||||
{
|
||||
id: 1,
|
||||
updated_at: '2026-09-27T20:36:14+00:00',
|
||||
alternates: {
|
||||
es: '/sobre-nosotros',
|
||||
en: '/en/about-us',
|
||||
ca: '/ca/qui-som',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('buildSitemap', () => {
|
||||
it('lists every language of a record as its own url', () => {
|
||||
for (const path of ['/sobre-nosotros', '/en/about-us', '/ca/qui-som']) {
|
||||
expect(xml).toContain(`<loc>https://pireon.dev${path}</loc>`);
|
||||
}
|
||||
});
|
||||
|
||||
it('states every language on each url, with the default as x-default', () => {
|
||||
expect(xml).toContain(
|
||||
'hreflang="en" href="https://pireon.dev/en/about-us"',
|
||||
);
|
||||
expect(xml).toContain(
|
||||
'hreflang="x-default" href="https://pireon.dev/sobre-nosotros"',
|
||||
);
|
||||
expect(xml).toContain('<lastmod>2026-09-27T20:36:14+00:00</lastmod>');
|
||||
});
|
||||
|
||||
it('includes the landing and the listings this site composes', () => {
|
||||
expect(xml).toContain('<loc>https://pireon.dev/</loc>');
|
||||
// `&` escaped, as XML requires.
|
||||
expect(xml).toContain('<loc>https://pireon.dev/?lang=en</loc>');
|
||||
expect(xml).toContain('<loc>https://pireon.dev/radar</loc>');
|
||||
expect(xml).toContain('<loc>https://pireon.dev/ca/col-labora</loc>');
|
||||
});
|
||||
|
||||
it('is a well-formed document', () => {
|
||||
const document = new DOMParser().parseFromString(xml, 'application/xml');
|
||||
|
||||
expect(document.getElementsByTagName('parsererror')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
85
landing/src/app/core/sitemap.ts
Normal file
85
landing/src/app/core/sitemap.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* `sitemap.xml`, built from the API's `/slugs` and the addresses this site
|
||||
* composes itself.
|
||||
*
|
||||
* Each record is one `<url>` per language, and each carries every language as
|
||||
* an `xhtml:link` — how a sitemap states `hreflang` for pages whose own HTML
|
||||
* is not fetched first.
|
||||
*/
|
||||
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SlugEntry } from './api';
|
||||
import {
|
||||
homeHref,
|
||||
listingAlternates,
|
||||
LISTINGS,
|
||||
type ListingType,
|
||||
} from './routes';
|
||||
|
||||
interface SitemapEntry {
|
||||
alternates: Record<string, string>;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export function buildSitemap(
|
||||
origin: string,
|
||||
slugs: Record<string, SlugEntry[]>,
|
||||
): string {
|
||||
const entries: SitemapEntry[] = [
|
||||
// The landing answers on `?lang=`, not a prefix.
|
||||
{
|
||||
alternates: Object.fromEntries(
|
||||
SUPPORTED_LOCALES.map((locale) => [locale, homeHref(locale)]),
|
||||
),
|
||||
},
|
||||
...(Object.keys(LISTINGS) as ListingType[]).map((listing) => ({
|
||||
alternates: listingAlternates(listing),
|
||||
})),
|
||||
...Object.values(slugs).flat(),
|
||||
];
|
||||
|
||||
const urls = entries.flatMap((entry) =>
|
||||
Object.values(entry.alternates).map((path) => url(origin, path, entry)),
|
||||
);
|
||||
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
|
||||
...urls,
|
||||
'</urlset>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function url(origin: string, path: string, entry: SitemapEntry): string {
|
||||
const links = Object.entries(entry.alternates).map(
|
||||
([locale, href]) =>
|
||||
` <xhtml:link rel="alternate" hreflang="${locale}" href="${escape(origin + href)}"/>`,
|
||||
);
|
||||
|
||||
// The default language doubles as the fallback for everyone else.
|
||||
const fallback = entry.alternates[DEFAULT_LOCALE];
|
||||
|
||||
if (fallback) {
|
||||
links.push(
|
||||
` <xhtml:link rel="alternate" hreflang="x-default" href="${escape(origin + fallback)}"/>`,
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
' <url>',
|
||||
` <loc>${escape(origin + path)}</loc>`,
|
||||
...(entry.updated_at
|
||||
? [` <lastmod>${escape(entry.updated_at)}</lastmod>`]
|
||||
: []),
|
||||
...links,
|
||||
' </url>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function escape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
Reference in New Issue
Block a user