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:
23
README.md
23
README.md
@@ -162,6 +162,9 @@ landing/src/
|
|||||||
server/
|
server/
|
||||||
routes/api/contact.post.ts forwards the contact form to the API
|
routes/api/contact.post.ts forwards the contact form to the API
|
||||||
routes/api/calls/[slug]/apply.post.ts forwards an application to one call
|
routes/api/calls/[slug]/apply.post.ts forwards an application to one call
|
||||||
|
routes/sitemap.xml.get.ts every published address, from the API's /slugs
|
||||||
|
routes/robots.txt.get.ts points crawlers at the sitemap
|
||||||
|
middleware/redirects.ts 301s for slugs renamed in the panel
|
||||||
middleware/not-found-status.ts
|
middleware/not-found-status.ts
|
||||||
styles.scss Material theme, `@pi/ui` sheets, shared primitives
|
styles.scss Material theme, `@pi/ui` sheets, shared primitives
|
||||||
```
|
```
|
||||||
@@ -251,6 +254,26 @@ cannot hold two parameters of different names at the same depth —
|
|||||||
Featured pages go in the header after the section anchors; every page is in the
|
Featured pages go in the header after the section anchors; every page is in the
|
||||||
footer.
|
footer.
|
||||||
|
|
||||||
|
## Search engines and link previews
|
||||||
|
|
||||||
|
- **`/sitemap.xml`** comes from the API's `/slugs` — one request for every
|
||||||
|
published address — plus the landing and the listings. Each address is listed
|
||||||
|
in every language, with `hreflang` alternates and `x-default`. If the API is
|
||||||
|
down it answers 503, not a sitemap missing half the site.
|
||||||
|
- **`/robots.txt`** allows everything and names the sitemap, absolute, on
|
||||||
|
whatever domain the proxy says this is.
|
||||||
|
- **Renamed slugs redirect.** The panel writes a redirect the moment somebody
|
||||||
|
renames one; `middleware/redirects.ts` answers the old address with a 301,
|
||||||
|
from the API's `/redirects` list kept for a minute (and kept whole through an
|
||||||
|
outage, so renamed links do not turn back into 404s).
|
||||||
|
- **Every page says what it is**: title, description, `og:*` with the record's
|
||||||
|
own picture (the logo otherwise), and schema.org JSON-LD — `Organization` on
|
||||||
|
the landing, `WebPage`, `CreativeWork`, `Person` or `Service` elsewhere.
|
||||||
|
|
||||||
|
In development only `/api/*` reaches Nitro, so `/sitemap.xml` and
|
||||||
|
`/robots.txt` answer 404 under `npm start`; build and run `npm run preview` to
|
||||||
|
see them. Middleware does run in development.
|
||||||
|
|
||||||
## Rendering and caching
|
## Rendering and caching
|
||||||
|
|
||||||
Nothing is prerendered: the page is assembled from content the panel publishes
|
Nothing is prerendered: the page is assembled from content the panel publishes
|
||||||
|
|||||||
@@ -185,7 +185,28 @@ async function list<T>(path: string, options: GetOptions = {}): Promise<ApiResul
|
|||||||
return { ...result, data: result.data ?? [] };
|
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 = {
|
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 }),
|
site: (lang: Locale) => get<SitePayload>('/site', { lang }),
|
||||||
|
|
||||||
services: (lang: Locale, query?: GetOptions['query']) =>
|
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, ''');
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { toSignal } from '@angular/core/rxjs-interop';
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { Meta, Title } from '@angular/platform-browser';
|
import { Meta, Title } from '@angular/platform-browser';
|
||||||
import { injectLoad, type RouteMeta } from '@analogjs/router';
|
import { injectLoad, type RouteMeta } from '@analogjs/router';
|
||||||
import { injectResponse } from '@analogjs/router/tokens';
|
import { injectRequest, injectResponse } from '@analogjs/router/tokens';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PiCardCenterContentDirective,
|
PiCardCenterContentDirective,
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
import { pageNavLinks } from '../core/routed-page';
|
import { pageNavLinks } from '../core/routed-page';
|
||||||
import { listingPath } from '../core/routes';
|
import { listingPath } from '../core/routes';
|
||||||
import { copyFor } from '../core/copy';
|
import { copyFor } from '../core/copy';
|
||||||
|
import { absoluteUrl, setShareImage, setStructuredData } from '../core/head';
|
||||||
|
import { originFromHeaders } from '../core/origin';
|
||||||
import { CallCard } from '../ui/call-card';
|
import { CallCard } from '../ui/call-card';
|
||||||
import { ClientLogo } from '../ui/client-logo';
|
import { ClientLogo } from '../ui/client-logo';
|
||||||
import { ContactForm } from '../ui/contact-form';
|
import { ContactForm } from '../ui/contact-form';
|
||||||
@@ -284,6 +286,9 @@ export default class HomePage {
|
|||||||
private readonly title = inject(Title);
|
private readonly title = inject(Title);
|
||||||
private readonly meta = inject(Meta);
|
private readonly meta = inject(Meta);
|
||||||
|
|
||||||
|
/** Absolute URLs for the preview tags and the organisation's data. */
|
||||||
|
private readonly origin = originFromHeaders(injectRequest()?.headers);
|
||||||
|
|
||||||
protected readonly data = toSignal(injectLoad<typeof load>(), {
|
protected readonly data = toSignal(injectLoad<typeof load>(), {
|
||||||
requireSync: true,
|
requireSync: true,
|
||||||
});
|
});
|
||||||
@@ -421,6 +426,18 @@ export default class HomePage {
|
|||||||
: `Pireon — ${copy.hero.title} ${copy.hero.highlight}`
|
: `Pireon — ${copy.hero.title} ${copy.hero.highlight}`
|
||||||
);
|
);
|
||||||
this.meta.updateTag({ name: 'description', content: copy.hero.lead });
|
this.meta.updateTag({ name: 'description', content: copy.hero.lead });
|
||||||
|
this.meta.updateTag({ property: 'og:description', content: copy.hero.lead });
|
||||||
|
setShareImage(this.meta, this.origin, null);
|
||||||
|
|
||||||
|
// Who the site is, for search engines: the one piece of structured data
|
||||||
|
// the landing has that no record page does.
|
||||||
|
setStructuredData(this.document, {
|
||||||
|
'@type': 'Organization',
|
||||||
|
name: 'Pireon',
|
||||||
|
url: absoluteUrl(this.origin, '/'),
|
||||||
|
logo: absoluteUrl(this.origin, '/logo.png'),
|
||||||
|
description: copy.hero.lead,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { injectRequest, injectResponse } from '@analogjs/router/tokens';
|
|||||||
|
|
||||||
import type { Alternates } from '../core/api.types';
|
import type { Alternates } from '../core/api.types';
|
||||||
import { copyFor } from '../core/copy';
|
import { copyFor } from '../core/copy';
|
||||||
|
import { absoluteUrl, setShareImage, setStructuredData } from '../core/head';
|
||||||
|
import { originFromHeaders } from '../core/origin';
|
||||||
import { pageNavLinks, type RoutedPageData } from '../core/routed-page';
|
import { pageNavLinks, type RoutedPageData } from '../core/routed-page';
|
||||||
import { homeHref } from '../core/routes';
|
import { homeHref } from '../core/routes';
|
||||||
import { MaintenanceScreen } from './maintenance-screen';
|
import { MaintenanceScreen } from './maintenance-screen';
|
||||||
@@ -27,6 +29,10 @@ export interface PageSeo {
|
|||||||
path?: string;
|
path?: string;
|
||||||
alternates: Alternates;
|
alternates: Alternates;
|
||||||
type: 'article' | 'profile' | 'website';
|
type: 'article' | 'profile' | 'website';
|
||||||
|
/** The record's own picture, for link previews. The site's logo otherwise. */
|
||||||
|
image?: string | null;
|
||||||
|
/** schema.org data for the record; `url` is added here, absolute. */
|
||||||
|
jsonLd?: Record<string, unknown> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,7 +87,7 @@ export class PageShell implements OnInit {
|
|||||||
private readonly meta = inject(Meta);
|
private readonly meta = inject(Meta);
|
||||||
|
|
||||||
/** Absolute, because `hreflang` and `canonical` want one. Null in the browser, where it is not needed. */
|
/** Absolute, because `hreflang` and `canonical` want one. Null in the browser, where it is not needed. */
|
||||||
private readonly origin = originOf(injectRequest());
|
private readonly origin = originFromHeaders(injectRequest()?.headers);
|
||||||
|
|
||||||
/** Taken here, in the injection context; used once the inputs have arrived. */
|
/** Taken here, in the injection context; used once the inputs have arrived. */
|
||||||
private readonly response = injectResponse();
|
private readonly response = injectResponse();
|
||||||
@@ -118,6 +124,7 @@ export class PageShell implements OnInit {
|
|||||||
if (!seo) {
|
if (!seo) {
|
||||||
this.title.setTitle('Pireon — Página no encontrada');
|
this.title.setTitle('Pireon — Página no encontrada');
|
||||||
this.meta.updateTag({ name: 'robots', content: 'noindex' });
|
this.meta.updateTag({ name: 'robots', content: 'noindex' });
|
||||||
|
setStructuredData(this.document, null);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -131,6 +138,24 @@ export class PageShell implements OnInit {
|
|||||||
});
|
});
|
||||||
this.meta.updateTag({ property: 'og:type', content: seo.type });
|
this.meta.updateTag({ property: 'og:type', content: seo.type });
|
||||||
|
|
||||||
|
if (seo.path) {
|
||||||
|
this.meta.updateTag({
|
||||||
|
property: 'og:url',
|
||||||
|
content: absoluteUrl(this.origin, seo.path),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setShareImage(this.meta, this.origin, seo.image);
|
||||||
|
setStructuredData(
|
||||||
|
this.document,
|
||||||
|
seo.jsonLd
|
||||||
|
? {
|
||||||
|
...seo.jsonLd,
|
||||||
|
...(seo.path ? { url: absoluteUrl(this.origin, seo.path) } : {}),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
this.linkAlternates(seo.path, seo.alternates ?? {});
|
this.linkAlternates(seo.path, seo.alternates ?? {});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -216,23 +241,3 @@ export class PageShell implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The origin a visitor used, as the reverse proxy in front of this app reports it. */
|
|
||||||
function originOf(request: ReturnType<typeof injectRequest>): string | null {
|
|
||||||
if (!request) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const first = (value: string | string[] | undefined) =>
|
|
||||||
(Array.isArray(value) ? value[0] : value)?.split(',')[0]?.trim();
|
|
||||||
|
|
||||||
const host =
|
|
||||||
first(request.headers['x-forwarded-host']) ?? first(request.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(request.headers['x-forwarded-proto']) ??
|
|
||||||
(/^(localhost|127\.)/.test(host ?? '') ? 'http' : 'https');
|
|
||||||
|
|
||||||
return host ? `${protocol}://${host}` : null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
|
|
||||||
import { copyFor, type Copy } from '../core/copy';
|
import { copyFor, type Copy } from '../core/copy';
|
||||||
import { listingAlternates, listingPath } from '../core/routes';
|
import { listingAlternates, listingPath } from '../core/routes';
|
||||||
|
import { memberLinks } from './member-card';
|
||||||
import type { Locale } from '../core/api';
|
import type { Locale } from '../core/api';
|
||||||
import { RadarPage } from './radar-page';
|
import { RadarPage } from './radar-page';
|
||||||
import { ServiceDetail } from './service-detail';
|
import { ServiceDetail } from './service-detail';
|
||||||
@@ -121,6 +122,13 @@ export function seoFor(
|
|||||||
path: page.path,
|
path: page.path,
|
||||||
alternates: page.alternates ?? {},
|
alternates: page.alternates ?? {},
|
||||||
type: 'article',
|
type: 'article',
|
||||||
|
image: page.featured_image?.url,
|
||||||
|
jsonLd: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
name: page.title,
|
||||||
|
description: page.excerpt,
|
||||||
|
inLanguage: page.locale,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'project': {
|
case 'project': {
|
||||||
@@ -132,6 +140,26 @@ export function seoFor(
|
|||||||
path: project.path,
|
path: project.path,
|
||||||
alternates: project.alternates ?? {},
|
alternates: project.alternates ?? {},
|
||||||
type: 'article',
|
type: 'article',
|
||||||
|
image: project.cover_image?.url,
|
||||||
|
jsonLd: {
|
||||||
|
'@type': 'CreativeWork',
|
||||||
|
name: project.title,
|
||||||
|
description: project.tagline,
|
||||||
|
inLanguage: project.locale,
|
||||||
|
dateCreated: project.started_at,
|
||||||
|
dateModified: project.updated_at,
|
||||||
|
keywords:
|
||||||
|
(project.technologies ?? [])
|
||||||
|
.map((technology) => technology.name)
|
||||||
|
.join(', ') || undefined,
|
||||||
|
creator: (project.members ?? []).map((member) => ({
|
||||||
|
'@type': 'Person',
|
||||||
|
name: member.name,
|
||||||
|
})),
|
||||||
|
...(project.client
|
||||||
|
? { funder: { '@type': 'Organization', name: project.client.name } }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'member': {
|
case 'member': {
|
||||||
@@ -143,6 +171,16 @@ export function seoFor(
|
|||||||
path: member.path,
|
path: member.path,
|
||||||
alternates: member.alternates ?? {},
|
alternates: member.alternates ?? {},
|
||||||
type: 'profile',
|
type: 'profile',
|
||||||
|
image: member.avatar?.url,
|
||||||
|
jsonLd: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: member.name,
|
||||||
|
jobTitle: member.headline,
|
||||||
|
description: member.bio,
|
||||||
|
image: member.avatar?.url,
|
||||||
|
sameAs: memberLinks(member).map((link) => link.url),
|
||||||
|
memberOf: { '@type': 'Organization', name: 'Pireon' },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'service': {
|
case 'service': {
|
||||||
@@ -154,6 +192,12 @@ export function seoFor(
|
|||||||
path: service.path,
|
path: service.path,
|
||||||
alternates: service.alternates ?? {},
|
alternates: service.alternates ?? {},
|
||||||
type: 'article',
|
type: 'article',
|
||||||
|
jsonLd: {
|
||||||
|
'@type': 'Service',
|
||||||
|
name: service.title,
|
||||||
|
description: service.tagline,
|
||||||
|
provider: { '@type': 'Organization', name: 'Pireon' },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'contribute':
|
case 'contribute':
|
||||||
@@ -163,6 +207,11 @@ export function seoFor(
|
|||||||
path: listingPath('contribute', locale),
|
path: listingPath('contribute', locale),
|
||||||
alternates: listingAlternates('contribute'),
|
alternates: listingAlternates('contribute'),
|
||||||
type: 'website',
|
type: 'website',
|
||||||
|
jsonLd: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
name: copy.contribute.title,
|
||||||
|
description: copy.contribute.lead,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
case 'radar':
|
case 'radar':
|
||||||
return {
|
return {
|
||||||
@@ -171,6 +220,11 @@ export function seoFor(
|
|||||||
path: listingPath('radar', locale),
|
path: listingPath('radar', locale),
|
||||||
alternates: listingAlternates('radar'),
|
alternates: listingAlternates('radar'),
|
||||||
type: 'website',
|
type: 'website',
|
||||||
|
jsonLd: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
name: copy.sections.radar.title,
|
||||||
|
description: copy.radar.lead,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
case 'call': {
|
case 'call': {
|
||||||
const call = found.record;
|
const call = found.record;
|
||||||
@@ -182,6 +236,12 @@ export function seoFor(
|
|||||||
path: call.path,
|
path: call.path,
|
||||||
alternates: call.alternates ?? {},
|
alternates: call.alternates ?? {},
|
||||||
type: 'article',
|
type: 'article',
|
||||||
|
image: call.project?.cover_image?.url,
|
||||||
|
jsonLd: {
|
||||||
|
'@type': 'WebPage',
|
||||||
|
name: call.title,
|
||||||
|
description: call.description,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
@@ -171,6 +171,18 @@ for (const listed of data['/public/v1/members']) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What the sitemap is built from, and one renamed project to exercise the
|
||||||
|
// redirect middleware with.
|
||||||
|
data['/public/v1/slugs'] = {
|
||||||
|
pages: data['/public/v1/pages'].map((p) => ({ id: p.id, updated_at: p.updated_at, alternates: p.alternates })),
|
||||||
|
projects: data['/public/v1/projects'].map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
updated_at: p.updated_at,
|
||||||
|
alternates: data[`/public/v1/projects/${p.slug}`].alternates,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
data['/public/v1/redirects'] = [{ from: '/proyectos/catalogo-viejo', to: '/proyectos/catalogo', status: 301, locale: null }];
|
||||||
|
|
||||||
createServer((req, res) => {
|
createServer((req, res) => {
|
||||||
const path = new URL(req.url, 'http://x').pathname.replace(/^\/api/, '');
|
const path = new URL(req.url, 'http://x').pathname.replace(/^\/api/, '');
|
||||||
const payload = data[path];
|
const payload = data[path];
|
||||||
|
|||||||
Reference in New Issue
Block a user