diff --git a/landing-e2e/src/landing.spec.ts b/landing-e2e/src/landing.spec.ts index bc7f28b..0ceb2e6 100644 --- a/landing-e2e/src/landing.spec.ts +++ b/landing-e2e/src/landing.spec.ts @@ -108,6 +108,15 @@ test.describe('landing', () => { } }); + test('answers 404 for a technology it does not know', async ({ page }) => { + // Shaped like a technology address, so the middleware lets it through and + // the status comes from the page once the collection had no such slug. + const response = await page.goto('/tecnologias/no-such-technology'); + + expect(response?.status()).toBe(404); + await expect(page.locator('h1')).toContainText('no existe'); + }); + test('refuses an incomplete contact submission without calling the API', async ({ page, }) => { diff --git a/landing/src/app/core/api.ts b/landing/src/app/core/api.ts index 9feffc5..4c6820c 100644 --- a/landing/src/app/core/api.ts +++ b/landing/src/app/core/api.ts @@ -238,6 +238,39 @@ export const pireonApi = { technologies: (lang: Locale, query?: GetOptions['query']) => list('/technologies', { lang, query }), + /** + * One technology, by slug. + * + * The public API has no `/technologies/{slug}` — that route does not exist, + * so asking for one answers "route not found" rather than "no such record". + * This filters the collection instead: a couple of dozen records in a single + * request the route's `s-maxage` already caches. + * + * `in_use: false` because the parameter defaults to true, which returns only + * technologies attached to a project — none of them, today. + * + * A failed call and an unknown slug have to stay distinguishable: the first + * is an outage, the second is a 404. So the collection's `error` is passed + * through untouched and only a successful, complete answer can conclude that + * a technology does not exist. + */ + technology: async ( + lang: Locale, + slug: string + ): Promise> => { + const all = await list('/technologies', { + lang, + query: { in_use: false }, + }); + + return { + ...all, + data: all.error + ? null + : (all.data?.find((technology) => technology.slug === slug) ?? null), + }; + }, + goodFirstIssues: (query?: GetOptions['query']) => list('/good-first-issues', { query }), diff --git a/landing/src/app/core/copy.ts b/landing/src/app/core/copy.ts index 9cad5f3..dd9f896 100644 --- a/landing/src/app/core/copy.ts +++ b/landing/src/app/core/copy.ts @@ -143,6 +143,14 @@ export interface Copy { testimonials: string; technologies: string; }; + technology: { + eyebrow: string; + ringTitle: string; + website: string; + usedIn: string; + membersUsing: string; + back: string; + }; radar: { eyebrow: string; lead: string; @@ -325,6 +333,14 @@ const es: Copy = { testimonials: 'Qué dicen', technologies: 'Con qué lo hacemos', }, + technology: { + eyebrow: 'Tecnología', + ringTitle: 'En el radar', + website: 'Web oficial', + usedIn: 'proyectos la usan', + membersUsing: 'personas la usan', + back: 'Ver el radar completo', + }, radar: { eyebrow: 'Radar tecnológico', lead: 'Las herramientas con las que trabajamos y lo que opinamos de cada una: qué usamos sin dudar, qué estamos probando y qué preferimos evitar.', @@ -520,6 +536,14 @@ const en: Copy = { testimonials: 'What people say', technologies: 'What we use', }, + technology: { + eyebrow: 'Technology', + ringTitle: 'On the radar', + website: 'Official site', + usedIn: 'projects use it', + membersUsing: 'people use it', + back: 'See the whole radar', + }, radar: { eyebrow: 'Tech radar', lead: 'The tools we work with and what we think of each: what we use without a second thought, what we are trying out and what we would rather avoid.', @@ -715,6 +739,14 @@ const ca: Copy = { testimonials: 'Què en diuen', technologies: 'Amb què ho fem', }, + technology: { + eyebrow: 'Tecnologia', + ringTitle: 'Al radar', + website: 'Web oficial', + usedIn: 'projectes la fan servir', + membersUsing: 'persones la fan servir', + back: 'Veure el radar complet', + }, radar: { eyebrow: 'Radar tecnològic', lead: "Les eines amb què treballem i què en pensem de cadascuna: què fem servir sense dubtar, què estem provant i què preferim evitar.", diff --git a/landing/src/app/core/routed-page.ts b/landing/src/app/core/routed-page.ts index eaabf04..987309f 100644 --- a/landing/src/app/core/routed-page.ts +++ b/landing/src/app/core/routed-page.ts @@ -32,6 +32,7 @@ export type RoutedRecord = | { type: 'member'; record: PublicMember } | { type: 'call'; record: PublicCall } | { type: 'service'; record: PublicService } + | { type: 'technology'; record: PublicTechnology } | { type: 'radar'; record: PublicTechnology[] } | { type: 'blog'; record: PublicPage[] } | { @@ -94,6 +95,10 @@ async function fetchRecord(route: RouteMatch): Promise { const { data } = await pireonApi.service(locale, slug); return data ? { type: 'service', record: data } : null; } + case 'technology': { + const { data } = await pireonApi.technology(locale, slug); + return data ? { type: 'technology', record: data } : null; + } case 'blog': { const { data, error } = await pireonApi.pages(locale, { category: BLOG_CATEGORY[locale], diff --git a/landing/src/app/core/routes.spec.ts b/landing/src/app/core/routes.spec.ts index 396b39b..db71f98 100644 --- a/landing/src/app/core/routes.spec.ts +++ b/landing/src/app/core/routes.spec.ts @@ -7,6 +7,8 @@ import { listingPath, resolveRoute, SECTIONS, + technologyAlternates, + technologyPath, } from './routes'; describe('resolveRoute', () => { @@ -121,6 +123,48 @@ describe('listings', () => { }); }); +describe('technologies', () => { + it('reads one under its section, in each language', () => { + expect(resolveRoute('/tecnologias/angular')).toEqual({ + type: 'technology', + locale: 'es', + slug: 'angular', + }); + expect(resolveRoute('/en/technologies/angular')).toEqual({ + type: 'technology', + locale: 'en', + slug: 'angular', + }); + expect(resolveRoute('/ca/tecnologies/angular')).toEqual({ + type: 'technology', + locale: 'ca', + slug: 'angular', + }); + }); + + it('builds the address resolveRoute reads back', () => { + for (const locale of SUPPORTED_LOCALES) { + const path = technologyPath('angular', locale); + + expect(resolveRoute(path)).toEqual({ + type: 'technology', + locale, + slug: 'angular', + }); + } + }); + + // The slug is a proper noun the API does not translate, so the three + // addresses differ only by the prefix. + it('keeps the same slug in every language', () => { + expect(technologyAlternates('angular')).toEqual({ + es: '/tecnologias/angular', + en: '/en/technologies/angular', + ca: '/ca/tecnologies/angular', + }); + }); +}); + describe('homeHref', () => { it('answers on ?lang= for every language but the default', () => { expect(homeHref(DEFAULT_LOCALE)).toBe('/'); diff --git a/landing/src/app/core/routes.ts b/landing/src/app/core/routes.ts index 3cb7359..918daf8 100644 --- a/landing/src/app/core/routes.ts +++ b/landing/src/app/core/routes.ts @@ -13,7 +13,13 @@ */ import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api'; -export type RecordType = 'page' | 'project' | 'member' | 'call' | 'service'; +export type RecordType = + | 'page' + | 'project' + | 'member' + | 'call' + | 'service' + | 'technology'; /** Pages this site composes itself rather than one record's: no slug. */ export type ListingType = 'radar' | 'contribute' | 'blog'; @@ -31,6 +37,11 @@ export const SECTIONS: Record< member: { es: 'equipo', en: 'team', ca: 'equip' }, call: { es: 'colabora', en: 'contribute', ca: 'col-labora' }, service: { es: 'servicios', en: 'services', ca: 'serveis' }, + // The one section the API does not know about. It gives technologies no + // `path` and no `alternates`, and `/slugs` cannot carry them — its type + // vocabulary is a closed enum. So this address is the site's own. Should the + // API ever publish one, this is where the two have to be reconciled. + technology: { es: 'tecnologias', en: 'technologies', ca: 'tecnologies' }, }; /** @@ -100,6 +111,26 @@ export function listingAlternates( ); } +/** + * A technology's address in one language, as `resolveRoute` reads it back. + * + * Unlike every other record, the slug is the same in all three: a technology's + * name is a proper noun, so the API does not translate it and the addresses + * differ only by the prefix. + */ +export function technologyPath(slug: string, locale: Locale): string { + const prefix = locale === DEFAULT_LOCALE ? '' : `/${locale}`; + + return `${prefix}/${SECTIONS.technology[locale]}/${slug}`; +} + +/** The same technology in every language, for `hreflang`. */ +export function technologyAlternates(slug: string): Record { + return Object.fromEntries( + SUPPORTED_LOCALES.map((locale) => [locale, technologyPath(slug, locale)]), + ); +} + /** The landing itself in a given language. It answers on `?lang=`, not a prefix. */ export function homeHref(locale: Locale): string { return locale === DEFAULT_LOCALE ? '/' : `/?lang=${locale}`; diff --git a/landing/src/app/core/sitemap.spec.ts b/landing/src/app/core/sitemap.spec.ts index ecc5d60..4fd2e12 100644 --- a/landing/src/app/core/sitemap.spec.ts +++ b/landing/src/app/core/sitemap.spec.ts @@ -14,7 +14,16 @@ const xml = buildSitemap('https://pireon.dev', { }, }, ], -}); +}, [ + { + id: 1, + name: 'Angular', + slug: 'angular', + category: 'framework', + projects_count: 0, + members_count: 0, + }, +]); describe('buildSitemap', () => { it('lists every language of a record as its own url', () => { @@ -41,6 +50,21 @@ describe('buildSitemap', () => { expect(xml).toContain('https://pireon.dev/ca/col-labora'); }); + // The API gives technologies no address and `/slugs` cannot carry them, so + // these are composed here and have to be in the sitemap all the same. + it('includes the technology pages this site composes', () => { + expect(xml).toContain('https://pireon.dev/tecnologias/angular'); + expect(xml).toContain( + 'https://pireon.dev/en/technologies/angular', + ); + expect(xml).toContain( + 'https://pireon.dev/ca/tecnologies/angular', + ); + expect(xml).toContain( + 'hreflang="ca" href="https://pireon.dev/ca/tecnologies/angular"', + ); + }); + it('is a well-formed document', () => { const document = new DOMParser().parseFromString(xml, 'application/xml'); diff --git a/landing/src/app/core/sitemap.ts b/landing/src/app/core/sitemap.ts index 6396c79..0976068 100644 --- a/landing/src/app/core/sitemap.ts +++ b/landing/src/app/core/sitemap.ts @@ -7,10 +7,12 @@ * is not fetched first. */ import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type SlugEntry } from './api'; +import type { PublicTechnology } from './api.types'; import { homeHref, listingAlternates, LISTINGS, + technologyAlternates, type ListingType, } from './routes'; @@ -22,6 +24,7 @@ interface SitemapEntry { export function buildSitemap( origin: string, slugs: Record, + technologies: PublicTechnology[] = [], ): string { const entries: SitemapEntry[] = [ // The landing answers on `?lang=`, not a prefix. @@ -34,6 +37,12 @@ export function buildSitemap( alternates: listingAlternates(listing), })), ...Object.values(slugs).flat(), + // Technologies are absent from `/slugs` — the API gives them no address, + // so this site composes theirs. No ``: the payload has no + // `updated_at` to report. + ...technologies.map((technology) => ({ + alternates: technologyAlternates(technology.slug), + })), ]; const urls = entries.flatMap((entry) => diff --git a/landing/src/app/pages/(home).page.ts b/landing/src/app/pages/(home).page.ts index 50a19b3..2caf943 100644 --- a/landing/src/app/pages/(home).page.ts +++ b/landing/src/app/pages/(home).page.ts @@ -22,6 +22,7 @@ import { listingPath } from '../core/routes'; import { copyFor } from '../core/copy'; import { absoluteUrl, setShareImage, setStructuredData } from '../core/head'; import { originFromHeaders } from '../core/origin'; +import { TechnologyChip } from '../ui/technology-chip'; import { CallCard } from '../ui/call-card'; import { ClientLogo } from '../ui/client-logo'; import { ContactForm } from '../ui/contact-form'; @@ -72,6 +73,7 @@ export const routeMeta: RouteMeta = { SiteHeader, TalkCard, TestimonialCarousel, + TechnologyChip, ], styleUrl: './home.page.scss', template: ` @@ -123,7 +125,11 @@ export const routeMeta: RouteMeta = { >
@for (project of data().projects; track project.id) { - + }
@@ -176,11 +182,10 @@ export const routeMeta: RouteMeta = {
    @for (technology of group.technologies; track technology.id) {
  • - +
  • }
diff --git a/landing/src/app/ui/call-card.ts b/landing/src/app/ui/call-card.ts index d4b3f11..104f4ae 100644 --- a/landing/src/app/ui/call-card.ts +++ b/landing/src/app/ui/call-card.ts @@ -15,6 +15,7 @@ import { import type { Copy } from '../core/copy'; import type { Locale } from '../core/api'; import type { PublicCall } from '../core/api.types'; +import { TechnologyChip } from './technology-chip'; @Component({ selector: 'app-call-card', @@ -30,6 +31,7 @@ import type { PublicCall } from '../core/api.types'; PiTextXSmallDirective, PiTextColorDirective, PiTextWeightBoldDirective, + TechnologyChip, ], template: ` @for (technology of call().technologies; track technology.id) {
  • - +
  • } diff --git a/landing/src/app/ui/call-detail.ts b/landing/src/app/ui/call-detail.ts index 9c9c3c3..4f04a3c 100644 --- a/landing/src/app/ui/call-detail.ts +++ b/landing/src/app/ui/call-detail.ts @@ -17,6 +17,7 @@ import type { PublicCall } from '../core/api.types'; import type { Copy } from '../core/copy'; import { homeHref } from '../core/routes'; import { ContactForm } from './contact-form'; +import { TechnologyChip } from './technology-chip'; /** * A call for help on its own address, with the form to answer it. @@ -36,6 +37,7 @@ import { ContactForm } from './contact-form'; PiTextColorDirective, PiTextLargeDirective, PiTextWeightBoldDirective, + TechnologyChip, ], styleUrl: './call-detail.scss', template: ` @@ -82,10 +84,9 @@ import { ContactForm } from './contact-form'; } @for (technology of call().technologies ?? []; track technology.id) {
  • -
  • } diff --git a/landing/src/app/ui/member-detail.ts b/landing/src/app/ui/member-detail.ts index 757dae0..253d39e 100644 --- a/landing/src/app/ui/member-detail.ts +++ b/landing/src/app/ui/member-detail.ts @@ -18,6 +18,7 @@ import type { Copy } from '../core/copy'; import { ContactCta } from './contact-cta'; import { memberLinks } from './member-card'; import { ProjectCard } from './project-card'; +import { TechnologyChip } from './technology-chip'; /** * Someone in the collective, on their own address: who they are, what they @@ -37,6 +38,7 @@ import { ProjectCard } from './project-card'; PiTextLargeDirective, PiTextWeightBoldDirective, ProjectCard, + TechnologyChip, ], styleUrl: './member-detail.scss', template: ` @@ -100,7 +102,11 @@ import { ProjectCard } from './project-card';

    {{ copy().member.projects }}

    @for (project of member().projects; track project.id) { - + }
    @@ -146,11 +152,10 @@ import { ProjectCard } from './project-card';
      @for (technology of member().technologies; track technology.id) {
    • - +
    • }
    diff --git a/landing/src/app/ui/project-card.ts b/landing/src/app/ui/project-card.ts index c7b1a9d..54855ba 100644 --- a/landing/src/app/ui/project-card.ts +++ b/landing/src/app/ui/project-card.ts @@ -12,8 +12,10 @@ import { PiTextWeightBoldDirective, } from '@pi/ui'; +import type { Locale } from '../core/api'; import type { Copy } from '../core/copy'; import type { PublicProject } from '../core/api.types'; +import { TechnologyChip } from './technology-chip'; import { ApiImage } from './api-image'; /** The API's five project states onto the status chip's five tones. */ @@ -43,6 +45,7 @@ export const PROJECT_STATUS_TONES: Record< PiTextSmallDirective, PiTextColorDirective, PiTextWeightBoldDirective, + TechnologyChip, ], template: ` @for (technology of technologies(); track technology.id) {
  • - +
  • } @@ -131,6 +137,8 @@ export const PROJECT_STATUS_TONES: Record< export class ProjectCard { readonly project = input.required(); readonly copy = input.required(); + /** Only to address the technology chips, which link to their own pages. */ + readonly locale = input.required(); /** Enough to read the stack at a glance; the rest is noise on a card. */ protected readonly technologies = computed(() => diff --git a/landing/src/app/ui/project-detail.ts b/landing/src/app/ui/project-detail.ts index ca0fb4b..20c2b2a 100644 --- a/landing/src/app/ui/project-detail.ts +++ b/landing/src/app/ui/project-detail.ts @@ -20,6 +20,7 @@ import type { Copy } from '../core/copy'; import { ApiImage } from './api-image'; import { ContactCta } from './contact-cta'; import { PROJECT_STATUS_TONES } from './project-card'; +import { TechnologyChip } from './technology-chip'; /** * A project on its own address: what it is, why it was built the way it was, @@ -41,6 +42,7 @@ import { PROJECT_STATUS_TONES } from './project-card'; PiTextColorDirective, PiTextLargeDirective, PiTextWeightBoldDirective, + TechnologyChip, ], styleUrl: './project-detail.scss', template: ` @@ -79,10 +81,9 @@ import { PROJECT_STATUS_TONES } from './project-card'; track technology.id ) {
  • -
  • } diff --git a/landing/src/app/ui/radar-page.scss b/landing/src/app/ui/radar-page.scss index 69211e9..532ac68 100644 --- a/landing/src/app/ui/radar-page.scss +++ b/landing/src/app/ui/radar-page.scss @@ -144,3 +144,19 @@ color: var(--pi-color-primary); } } + +// The card's logo and name lead to the technology's own page. +.radar-card__top { + text-decoration: none; + color: inherit; + + &:hover .radar-card__name { + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid var(--pi-color-primary); + outline-offset: 4px; + border-radius: 0.5rem; + } +} diff --git a/landing/src/app/ui/radar-page.ts b/landing/src/app/ui/radar-page.ts index 6a0e00c..e33ae4b 100644 --- a/landing/src/app/ui/radar-page.ts +++ b/landing/src/app/ui/radar-page.ts @@ -13,7 +13,9 @@ import { } from '@pi/ui'; import type { PublicTechnology } from '../core/api.types'; +import type { Locale } from '../core/api'; import type { Copy } from '../core/copy'; +import { technologyPath } from '../core/routes'; import { ApiImage } from './api-image'; type Ring = NonNullable; @@ -81,7 +83,7 @@ const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
      @for (technology of group.technologies; track technology.id) {
    • -
      +
      - + @if (technology.description; as description) {

      {{ description }}

      @@ -148,6 +150,13 @@ const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold']; export class RadarPage { readonly technologies = input.required(); readonly copy = input.required(); + /** Only to address each technology's own page. */ + readonly locale = input.required(); + + /** Each card leads to the technology it names. */ + protected href(technology: PublicTechnology): string { + return technologyPath(technology.slug, this.locale()); + } /** The rings that have anything in them, in the radar's own order. */ protected readonly rings = computed(() => diff --git a/landing/src/app/ui/routed-page.ts b/landing/src/app/ui/routed-page.ts index 00a8bf6..a3441c0 100644 --- a/landing/src/app/ui/routed-page.ts +++ b/landing/src/app/ui/routed-page.ts @@ -6,12 +6,18 @@ import { } from '@angular/core'; import { copyFor, type Copy } from '../core/copy'; -import { listingAlternates, listingPath } from '../core/routes'; +import { + listingAlternates, + listingPath, + technologyAlternates, + technologyPath, +} from '../core/routes'; import { memberLinks } from './member-card'; import { isBlogPost } from '../core/blog'; import type { Locale } from '../core/api'; import { RadarPage } from './radar-page'; import { ServiceDetail } from './service-detail'; +import { TechnologyDetail } from './technology-detail'; import { BlogPage } from './blog-page'; import { CallDetail } from './call-detail'; import type { RoutedPageData, RoutedRecord } from '../core/routed-page'; @@ -38,6 +44,7 @@ import { ProjectDetail } from './project-detail'; ProjectDetail, RadarPage, ServiceDetail, + TechnologyDetail, ], template: ` @@ -88,7 +95,18 @@ import { ProjectDetail } from './project-detail'; /> } @case ('radar') { - + + } + @case ('technology') { + } @case ('member') { {{ copy().service.projects }}
      @for (project of service().projects; track project.id) { - + }
      @@ -100,11 +104,10 @@ import { TestimonialCard } from './testimonial-card';
        @for (technology of service().technologies; track technology.id) {
      • - +
      • }
      diff --git a/landing/src/app/ui/technology-chip.scss b/landing/src/app/ui/technology-chip.scss new file mode 100644 index 0000000..c1bfbb6 --- /dev/null +++ b/landing/src/app/ui/technology-chip.scss @@ -0,0 +1,22 @@ +:host { + display: inline-flex; +} + +.technology-chip { + display: inline-flex; + text-decoration: none; + border-radius: var(--pi-theme-chip-radius, 0.25rem); + + // The chip carries the visible border, so the focus ring goes on the anchor + // around it rather than being lost inside the component. + &:focus-visible { + outline: 2px solid var(--pi-color-primary); + outline-offset: 2px; + } + + &:hover ::ng-deep .pi-chip, + &:hover ::ng-deep span { + border-color: var(--pi-color-primary); + color: var(--pi-color-primary); + } +} diff --git a/landing/src/app/ui/technology-chip.ts b/landing/src/app/ui/technology-chip.ts new file mode 100644 index 0000000..6213651 --- /dev/null +++ b/landing/src/app/ui/technology-chip.ts @@ -0,0 +1,33 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { PiChipComponent } from '@pi/ui'; + +import type { Locale } from '../core/api'; +import type { PublicTechnology } from '../core/api.types'; +import { technologyPath } from '../core/routes'; + +/** + * A technology's name, leading to its page. + * + * The same chip was written out in nine places — every card and every detail + * page that names a technology — so the link lives here rather than nine + * times. `pi-chip` renders a ``, so the anchor goes around it. + */ +@Component({ + selector: 'app-technology-chip', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [PiChipComponent], + styleUrl: './technology-chip.scss', + template: ` + + + + `, +}) +export class TechnologyChip { + readonly technology = input.required(); + readonly locale = input.required(); + + protected readonly href = computed(() => + technologyPath(this.technology().slug, this.locale()), + ); +} diff --git a/landing/src/app/ui/technology-detail.scss b/landing/src/app/ui/technology-detail.scss new file mode 100644 index 0000000..d103719 --- /dev/null +++ b/landing/src/app/ui/technology-detail.scss @@ -0,0 +1,53 @@ +@use './detail'; + +:host { + display: block; +} + +.technology-hero { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 1.5rem; +} + +// The same light tile the radar uses, for the same reason: a brand's mark is +// not ours to recolour, and a black one would disappear in dark mode. +.technology-logo { + display: grid; + flex: none; + place-items: center; + width: 4.5rem; + height: 4.5rem; + border-radius: 1.125rem; + background: #fff; + box-shadow: + 0 0 0 1px + color-mix(in srgb, var(--brand, var(--pi-color-primary)) 22%, transparent), + 0 8px 20px -10px + color-mix(in srgb, var(--brand, var(--pi-color-primary)) 55%, transparent); +} + +.technology-logo ::ng-deep img { + display: block; + width: 3rem; + height: 3rem; + object-fit: contain; +} + +.technology-logo__initial { + font-weight: 700; + font-size: 1.5rem; + color: var(--brand, var(--pi-color-primary)); +} + +.technology-text { + margin: 0; + font-size: 1.0625rem; + line-height: 1.7; +} + +.technology-note { + margin-top: 0.75rem; + color: var(--pi-color-grey); +} diff --git a/landing/src/app/ui/technology-detail.ts b/landing/src/app/ui/technology-detail.ts new file mode 100644 index 0000000..fcc7bb1 --- /dev/null +++ b/landing/src/app/ui/technology-detail.ts @@ -0,0 +1,165 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { + PiActionButtonComponent, + PiTextColorDirective, + PiTextLargeDirective, + PiTextWeightBoldDirective, +} from '@pi/ui'; + +import type { Locale } from '../core/api'; +import type { PublicTechnology } from '../core/api.types'; +import type { Copy } from '../core/copy'; +import { listingPath } from '../core/routes'; +import { ApiImage } from './api-image'; +import { ContactCta } from './contact-cta'; + +/** + * One technology on its own address: what it is, where the collective places + * it on the radar, and where its own people document it. + * + * The logo sits on a light tile in both themes, as it does on the radar. A + * brand's own mark is not ours to recolour, and some — Rust's, black — would + * otherwise vanish against a dark page. + * + * `website` is the only link the API carries. It is the project's own home + * page, which is where its documentation lives; there is no separate field for + * the docs, so the label says what the link actually is. + */ +@Component({ + selector: 'app-technology-detail', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ApiImage, + ContactCta, + PiActionButtonComponent, + PiTextColorDirective, + PiTextLargeDirective, + PiTextWeightBoldDirective, + ], + styleUrl: './technology-detail.scss', + template: ` +
      +
      + + +
      +

      {{ category() }}

      +

      {{ technology().name }}

      + + @if (technology().description; as description) { +

      + {{ description }} +

      + } + + @if (technology().website; as website) { +

      + + + +

      + } +
      +
      +
      + +
      +
      + @if (ring(); as ring) { +
      +

      {{ copy().technology.ringTitle }}: {{ ring.label }}

      +

      {{ ring.lead }}

      + + @if (technology().radar_note; as note) { +

      {{ note }}

      + } +
      + } + +

      + {{ copy().technology.back }} → +

      +
      + + +
      + `, +}) +export class TechnologyDetail { + readonly technology = input.required(); + readonly copy = input.required(); + readonly locale = input.required(); + + protected readonly category = computed( + () => this.copy().radar.categories[this.technology().category], + ); + + protected readonly radarHref = computed(() => + listingPath('radar', this.locale()), + ); + + /** Null for a technology the collective uses but has not placed. */ + protected readonly ring = computed(() => { + const ring = this.technology().ring; + + return ring + ? { label: this.copy().rings[ring], lead: this.copy().radar.ringLead[ring] } + : null; + }); + + /** Counts the API reports as zero say nothing, so they are not shown. */ + protected readonly facts = computed(() => { + const { projects_count: projects, members_count: members } = + this.technology(); + const copy = this.copy().technology; + const facts: string[] = []; + + if (projects > 0) { + facts.push(`${projects} ${copy.usedIn}`); + } + + if (members > 0) { + facts.push(`${members} ${copy.membersUsing}`); + } + + return facts; + }); +} diff --git a/landing/src/server/routes/sitemap.xml.get.ts b/landing/src/server/routes/sitemap.xml.get.ts index e09773b..0c24bd0 100644 --- a/landing/src/server/routes/sitemap.xml.get.ts +++ b/landing/src/server/routes/sitemap.xml.get.ts @@ -13,13 +13,18 @@ import { setResponseStatus, } from 'h3'; -import { pireonApi } from '../../app/core/api'; +import { DEFAULT_LOCALE, 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(); + // The technologies are fetched alongside, not instead: their addresses are + // this site's own and `/slugs` cannot carry them. + const [{ data }, technologies] = await Promise.all([ + pireonApi.slugs(), + pireonApi.technologies(DEFAULT_LOCALE, { in_use: false }), + ]); if (!data) { setResponseStatus(event, 503); @@ -30,5 +35,9 @@ export default defineEventHandler(async (event) => { setResponseHeader(event, 'Content-Type', 'application/xml; charset=utf-8'); - return buildSitemap(origin, data); + // A failed `/slugs` is a 503 above, because a sitemap missing most of the + // site is worse than none. A failed technologies call is not: they are an + // extra this site adds, and leaving them out costs a handful of URLs rather + // than misrepresenting the whole site. + return buildSitemap(origin, data, technologies.data ?? []); }); diff --git a/tools/mock-api.mjs b/tools/mock-api.mjs index b81df00..150fd88 100644 --- a/tools/mock-api.mjs +++ b/tools/mock-api.mjs @@ -17,6 +17,15 @@ 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, + description: `${name} es una de las herramientas con las que trabajamos a diario. Esta descripción viene de la API, como en producción.`, + website: `https://example.com/${slug}`, + // An inline SVG so the mock needs no file server: the real API serves these + // from its own storage. + logo: { + url: `data:image/svg+xml;utf8,${encodeURIComponent(`${name[0]}`)}`, + alt: name, width: 64, height: 64, aspect_ratio: 1, + mime: 'image/svg+xml', placeholder: null, srcset: null, + }, }); const technologies = [ @@ -100,6 +109,7 @@ const data = { { 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/technologies': 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: {} }, @@ -201,6 +211,12 @@ createServer((req, res) => { let payload = data[path]; // The one filter the frontend relies on: pages by category slug. + if (path === '/public/v1/technologies') { + const inUse = url.searchParams.get('in_use'); + payload = + inUse === 'false' ? payload : payload.filter((t) => t.projects_count > 0); + } + if (path === '/public/v1/pages' && url.searchParams.get('category')) { payload = payload.filter((p) => p.category?.slug === url.searchParams.get('category')); }