A post is a page in the panel's "Blog" category, so it is written, translated, scheduled and reviewed like any page, and the MCP server can publish one the same way. Nothing new in the API. - /blog (/en/blog, /ca/blog): posts newest first, with image, date and excerpt. - A post shows its date and leads back to the blog; its structured data is a BlogPosting. - /blog/rss.xml?lang=xx: one feed per language, announced in <head>. - Posts stay out of the header and the footer, which links the blog once there is any post instead of growing with every one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
226 lines
14 KiB
JavaScript
226 lines
14 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 post = (id, title, slug, published_at) => ({
|
|
...page(id, title, slug, false),
|
|
published_at,
|
|
excerpt: 'Lo que hemos aprendido este mes, contado sin prisa y con ejemplos de código.',
|
|
category: { id: 9, name: 'Blog', slug: 'blog', position: 3 },
|
|
});
|
|
|
|
const pages = [
|
|
page(1, 'Sobre nosotros', 'sobre-nosotros', true),
|
|
page(2, 'Contacto', 'contacto', false),
|
|
post(3, 'Por qué volvimos a renderizar en el servidor', 'volvimos-al-servidor', '2026-09-20T09:00:00Z'),
|
|
post(4, 'Nuestro primer año como colectivo', 'primer-ano', '2026-06-01T09:00:00Z'),
|
|
];
|
|
|
|
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' },
|
|
],
|
|
};
|
|
|
|
// Detail endpoints: what a project's or a member's own page asks for. Richer
|
|
// than the listings — gallery, prose, team — because that is what the detail
|
|
// endpoints add.
|
|
const prose = (topic) =>
|
|
`<p>${topic} empezó como una necesidad concreta de una asociación y acabó siendo una herramienta que usan varias.</p>` +
|
|
'<ul><li>Pensado para que lo mantenga gente que no programa.</li><li>Con pruebas y documentación desde el primer día.</li></ul>';
|
|
|
|
const team = data['/public/v1/members'].slice(0, 3).map((m, i) => ({
|
|
...m,
|
|
role: ['Responsable', 'Frontend', 'Backend'][i],
|
|
}));
|
|
|
|
for (const listed of data['/public/v1/projects']) {
|
|
data[`/public/v1/projects/${listed.slug}`] = {
|
|
...listed,
|
|
description: prose(listed.title),
|
|
decisions: '<p>Elegimos renderizar en el servidor porque la mayoría de visitas llegan desde móviles modestos.</p>',
|
|
postmortem: '<p>Subestimamos la importación de datos antiguos: nos llevó el doble de lo previsto.</p>',
|
|
meta_title: `${listed.title} · Pireon`,
|
|
meta_description: listed.tagline,
|
|
started_at: '2025-03-01T00:00:00Z',
|
|
ended_at: listed.status === 'completed' ? '2026-06-01T00:00:00Z' : null,
|
|
client: data['/public/v1/clients'][0],
|
|
members: team,
|
|
lead: team[0],
|
|
calls: data['/public/v1/calls'].slice(0, listed.id % 2 ? 1 : 0).map((c) => ({ ...c, path: `/colabora/${c.slug}` })),
|
|
alternates: { es: `/proyectos/${listed.slug}`, en: `/en/projects/${listed.slug}`, ca: `/ca/projectes/${listed.slug}` },
|
|
};
|
|
}
|
|
|
|
// Calls link to their own page, and one of them is closed so the page can
|
|
// show what a visitor sees when a link to a filled call is still out there.
|
|
for (const call of data['/public/v1/calls']) {
|
|
call.path = `/colabora/${call.slug}`;
|
|
call.alternates = { es: call.path, en: `/en/contribute/${call.slug}`, ca: `/ca/col-labora/${call.slug}` };
|
|
data[`/public/v1/calls/${call.slug}`] = {
|
|
...call,
|
|
status: call.id === 2 ? 'filled' : 'open',
|
|
what_you_get: 'Revisiones de código con alguien del colectivo.\nUn proyecto real en tu portfolio.',
|
|
total_hours: call.hours_per_week * (call.duration_weeks ?? 4),
|
|
project: { ...call.project, path: `/proyectos/${call.project.slug}` },
|
|
};
|
|
}
|
|
|
|
for (const service of data['/public/v1/services']) {
|
|
service.path = `/servicios/${service.slug}`;
|
|
data[`/public/v1/services/${service.slug}`] = {
|
|
...service,
|
|
description: 'Empezamos escuchando: qué hace hoy la organización y qué le sobra.\nDespués construimos lo mínimo que resuelve el problema, y lo dejamos documentado para que lo pueda mantener otra persona.',
|
|
meta_description: service.tagline,
|
|
projects: data['/public/v1/projects'].slice(0, 2).map((p) => ({ ...p, path: `/proyectos/${p.slug}` })),
|
|
technologies: technologies.slice(0, 4),
|
|
testimonials: data['/public/v1/testimonials'].slice(0, 2),
|
|
alternates: { es: service.path, en: `/en/services/${service.slug}`, ca: `/ca/serveis/${service.slug}` },
|
|
};
|
|
}
|
|
|
|
for (const listed of data['/public/v1/members']) {
|
|
data[`/public/v1/members/${listed.slug}`] = {
|
|
...listed,
|
|
bio: 'Programa desde hace años en proyectos de todo tipo.\nAhora dedica unas horas a la semana al colectivo, sobre todo a revisar código y acompañar a quien empieza.',
|
|
joined_at: '2024-09-01T00:00:00Z',
|
|
technologies: technologies.slice(0, 4),
|
|
projects: data['/public/v1/projects'].slice(0, 2).map((p) => ({ ...p, path: `/proyectos/${p.slug}` })),
|
|
alternates: { es: `/equipo/${listed.slug}`, en: `/en/team/${listed.slug}`, ca: `/ca/equip/${listed.slug}` },
|
|
};
|
|
}
|
|
|
|
// 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) => {
|
|
const url = new URL(req.url, 'http://x');
|
|
const path = url.pathname.replace(/^\/api/, '');
|
|
let payload = data[path];
|
|
|
|
// The one filter the frontend relies on: pages by category slug.
|
|
if (path === '/public/v1/pages' && url.searchParams.get('category')) {
|
|
payload = payload.filter((p) => p.category?.slug === url.searchParams.get('category'));
|
|
}
|
|
|
|
res.setHeader('content-type', 'application/json');
|
|
|
|
// The two write endpoints: accept whatever arrives, as the real API does
|
|
// for a valid submission.
|
|
if (req.method === 'POST') {
|
|
res.statusCode = 201;
|
|
res.end(JSON.stringify({ data: { id: 1, kind: 'contribute', status: 'new' } }));
|
|
return;
|
|
}
|
|
|
|
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'));
|