Files
landing-app/tools/mock-api.mjs
pigui 80a1924519 Serve the pages the panel publishes
The landing was one address. It now also answers on the pages the panel
publishes -- "Sobre nosotros" and the like -- at the addresses the API
itself chooses: `path` and `alternates` put the default language
unprefixed and every other one under its code, so `/sobre-nosotros`,
`/en/about-us`, `/ca/qui-som` are the routes. `core/api.ts#contentRoute`
is the single rule for that shape, and `/es/…` is deliberately not one
of them, so a page has one address rather than two.

Two routes share one load function, because Analog wants a `.server.ts`
beside each `.page.ts`: `[slug]` for the default language, and
`[locale]/[slug]` for the rest.

Whether a slug exists is the API's answer, not something the route table
knows, so the 404 middleware lets page-shaped addresses through and
`ui/content-page.ts` sets the status itself from `ngOnInit` -- the same
`injectResponse()` the landing uses for its 503, and `ngOnInit` because
an input has no value in the constructor. Body HTML comes from the
panel's editor and goes through Angular's sanitiser.

The header shows the featured pages beside the section anchors; the
footer lists them all. On a page the language switcher follows the API's
`alternates`, and a language with no translation leads to the landing in
that language instead of a 404.

Verified: lint, 20 unit tests, build, and 6 e2e -- including a new one
that a page-shaped address the API does not know answers 404 with
`robots: noindex`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-28 09:55:10 +02:00

120 lines
8.5 KiB
JavaScript

/**
* A stand-in for the Pireon public API, with enough content to see every
* section of the landing at once.
*
* The real API answers `{"data": []}` for every collection until the panel has
* something published, so the page renders as a hero and a contact form and
* nothing else — correct, but impossible to review. Point the app at this
* instead:
*
* npm run mock # this server, on :4599
* npm run start:mock # the app against it
*
* `mock-api-down.mjs` is the other half: every endpoint answering 503, which is
* how the maintenance banner gets exercised.
*/
import { createServer } from 'node:http';
const tech = (id, name, slug, category, color, ring) => ({
id, name, slug, category, color, ring, projects_count: 3, members_count: 2,
});
const technologies = [
tech(1, 'Angular', 'angular', 'framework', '#dd0031', 'adopt'),
tech(2, 'TypeScript', 'typescript', 'language', '#3178c6', 'adopt'),
tech(3, 'Laravel', 'laravel', 'framework', '#ff2d20', 'trial'),
tech(4, 'Postgres', 'postgres', 'datastore', '#336791', 'assess'),
tech(5, 'Kubernetes', 'kubernetes', 'infrastructure', '#326ce5', 'hold'),
];
const project = (id, title, slug, status, featured) => ({
id, locale: 'es', title, slug, status,
tagline: 'Una herramienta pequeña que resuelve un problema concreto y aburrido.',
is_featured: featured, needs_help: id % 2 === 0,
gallery: [], services: [], testimonials: [], talks: [],
demo_url: 'https://example.com', updated_at: '2026-01-01T00:00:00Z',
technologies: technologies.slice(0, 3), members: [], calls: [],
repositories: [{ id, provider: 'gitea', full_name: `pireon/${slug}`, url: `https://git.example/${slug}`, languages: {}, topics: [], stars: 42 * id, forks: 3, contributors_count: 4, open_issues_count: 2, is_archived: false, is_dormant: false, good_first_issues: [] }],
alternates: {}, path: `/proyectos/${slug}`,
});
const member = (id, name, slug, founder) => ({
id, name, slug, headline: 'Backend y cosas que nadie quiere tocar',
links: { web: 'https://example.com' }, github_username: slug,
is_founder: founder, is_current: id !== 4, technologies: [], projects: [],
alternates: {}, path: `/equipo/${slug}`,
});
const page = (id, title, slug, featured) => ({
id, locale: 'es', title, slug, status: 'published', is_featured: featured,
excerpt: 'Una página publicada desde el panel, con su propio título, extracto y cuerpo.',
body: '<h2>Un apartado</h2><p>Texto de la página, tal como sale del editor del panel.</p><ul><li>Una lista</li><li>con dos elementos</li></ul>',
meta_title: `${title} · Pireon`, meta_description: 'Descripción para buscadores.',
published_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
category: { id: 1, name: 'El colectivo', slug: 'colectivo', color: '#4f46e5', position: 1 },
alternates: { es: `/${slug}`, en: `/en/${slug}-en`, ca: `/ca/${slug}-ca` }, path: `/${slug}`,
});
const pages = [page(1, 'Sobre nosotros', 'sobre-nosotros', true), page(2, 'Contacto', 'contacto', false)];
const data = {
'/public/v1/pages': pages,
...Object.fromEntries(pages.map((p) => [`/public/v1/pages/${p.slug}`, p])),
'/public/v1/site': {
locales: [
{ code: 'es', name: 'Español', flag: '🇪🇸', is_default: true },
{ code: 'en', name: 'English', flag: '🇬🇧', is_default: false },
{ code: 'ca', name: 'Català', flag: '🏳️', is_default: false },
],
default_locale: 'es', content_version: 12, submissions_enabled: true,
stats: { projects: 9, active_projects: 4, members: 7, open_calls: 2, stars: 1280, good_first_issues: 5 },
},
'/public/v1/services': [
{ id: 1, locale: 'es', title: 'Producto a medida', slug: 'producto', tagline: 'Del boceto a producción sin capas que nadie pidió.', icon: '🛠', is_featured: true, position: 1, projects: [], technologies: [], testimonials: [], alternates: {} },
{ id: 2, locale: 'es', title: 'Rescate de sistemas', slug: 'rescate', tagline: 'Código heredado que hay que entender antes de tocar.', icon: '🧭', is_featured: false, position: 2, projects: [], technologies: [], testimonials: [], alternates: {} },
{ id: 3, locale: 'es', title: 'Formación', slug: 'formacion', tagline: 'Enseñamos lo que usamos, con el repositorio abierto.', icon: '📚', is_featured: false, position: 3, projects: [], technologies: [], testimonials: [], alternates: {} },
],
'/public/v1/projects': [project(1, 'Catálogo abierto', 'catalogo', 'active', true), project(2, 'Migrador de datos', 'migrador', 'completed', false), project(3, 'Panel de métricas', 'panel', 'paused', false)],
'/public/v1/clients': [
{ id: 1, name: 'Ajuntament de Girona', slug: 'girona', sector: 'Público', url: 'https://example.com', projects_count: 2 },
{ id: 2, name: 'Cooperativa Verd', slug: 'verd', sector: 'Cooperativa', projects_count: 1 },
{ id: 3, name: 'Fundació Oberta', slug: 'oberta', projects_count: 3 },
],
// Four, so the carousel actually has a second page and shows its controls.
'/public/v1/testimonials': [
{ id: 1, quote: 'Entregaron antes de tiempo y nos dejaron la documentación que nunca habíamos tenido.', original_locale: 'es', is_original: true, author: { name: 'Marta Ruiz', role: 'Directora técnica' }, client: { id: 1, name: 'Ajuntament de Girona', slug: 'girona', projects_count: 2 } },
{ id: 2, quote: 'Explican las decisiones. Eso vale más que el código.', original_locale: 'es', is_original: true, author: { name: 'Joan Pi', role: 'CTO' } },
{ id: 3, quote: 'Heredamos un sistema que nadie entendía. Lo documentaron, lo simplificaron y nos enseñaron a mantenerlo nosotros.', original_locale: 'es', is_original: true, author: { name: 'Nuria Sanz', role: 'Responsable de sistemas' }, client: { id: 2, name: 'Cooperativa Verd', slug: 'verd', projects_count: 1 } },
{ id: 4, quote: 'Trabajar con el repositorio abierto desde el primer día nos quitó el miedo a depender de nadie.', original_locale: 'es', is_original: true, author: { name: 'Pau Ferrer', role: 'Dirección' }, client: { id: 3, name: 'Fundació Oberta', slug: 'oberta', projects_count: 3 } },
],
'/public/v1/radar': technologies,
'/public/v1/members': [member(1, 'Ada Lovelace', 'ada', true), member(2, 'Linus T.', 'linus', false), member(3, 'Grace Hopper', 'grace', true), member(4, 'Alan Turing', 'alan', false)],
'/public/v1/calls': [
{ id: 1, locale: 'es', title: 'Frontend Angular', slug: 'frontend-angular', description: 'Ayúdanos con el catálogo público: componentes, accesibilidad y tests.', status: 'open', hours_per_week: 4, duration_weeks: 8, seniority: 'any', closes_at: '2026-10-01T00:00:00Z', project: project(1, 'Catálogo abierto', 'catalogo', 'active', true), technologies: technologies.slice(0, 2), alternates: {} },
{ id: 2, locale: 'es', title: 'Infra y despliegues', slug: 'infra', description: 'Automatizar lo que ahora hacemos a mano una vez por semana.', status: 'open', hours_per_week: 2, seniority: 'mid', project: project(3, 'Panel de métricas', 'panel', 'paused', false), technologies: technologies.slice(4), alternates: {} },
],
'/public/v1/good-first-issues': [
{ number: 12, title: 'Añadir mensajes de error traducidos al formulario', url: 'https://git.example/issues/12', labels: ['good first issue'], is_good_first_issue: true, is_help_wanted: false, is_available: true, comments: 3, repository: { full_name: 'pireon/catalogo', primary_language: 'TypeScript' } },
{ number: 31, title: 'Documentar el proceso de despliegue', url: 'https://git.example/issues/31', labels: ['help wanted'], is_good_first_issue: false, is_help_wanted: true, is_available: true, comments: 0, repository: { full_name: 'pireon/panel', primary_language: 'PHP' } },
],
'/public/v1/talks': [
{ id: 1, title: 'Servir HTML otra vez', language: 'es', summary: 'Por qué volvimos a renderizar en el servidor y qué nos costó.', event: 'Codemotion', city: 'Madrid', held_at: '2026-05-12T00:00:00Z', video_url: 'https://example.com/video' },
{ id: 2, title: 'Una API para un frontend que no controlas', language: 'es', event: 'DevFest', city: 'Barcelona', held_at: '2026-03-02T00:00:00Z', slides_url: 'https://example.com/slides' },
],
};
createServer((req, res) => {
const path = new URL(req.url, 'http://x').pathname.replace(/^\/api/, '');
const payload = data[path];
res.setHeader('content-type', 'application/json');
if (payload === undefined) {
res.statusCode = 404;
res.end(JSON.stringify({ message: 'not found' }));
return;
}
res.end(JSON.stringify({ data: payload }));
}).listen(4599, () => console.log('Mock API on http://localhost:4599/api'));