Compare commits

..

33 Commits

Author SHA1 Message Date
pigui
289af6c3a1 Update @pi/ui to 0.0.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:15:06 +02:00
pigui
1ee4f98a9e Update @pi/ui to 0.0.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:18:59 +02:00
pigui
3366f57e14 Update @pi/ui to 0.0.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:33:58 +02:00
pigui
334640d938 Update @pi/ui to 0.0.4
It no longer registers Chart.js on import nor depends on lodash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:55:02 +02:00
pigui
63d4410296 Show proposals and votes from TaskStream on a project's page
The most voted bug reports and ideas, the link to send one while the
board takes them, and how many want each epic on the roadmap. The mock
API serves a showcase for the catalogue so every section can be seen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:21:40 +02:00
pigui
a3783222c8 Show a project's roadmap, releases and first issues from TaskStream
When a project has a board in TaskStream, its page lists the epics with
their dates and progress, the releases with their notes, the sprint under
way and the tickets meant for newcomers, linking to the board. TaskStream
being slow or down only leaves those sections out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:13:58 +02:00
pigui
bca3e4bcc6 Show who signs a post and its tags, and read the blog by tag
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:20:57 +02:00
pigui
918465a8ab Keep posts with code inside a phone's width
The single-column layout grew to its widest line, so a code block pushed the
whole text past the edge of the screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:12:19 +02:00
pigui
e9082b90ee Restore the numeric field's left inset
The value in "Horas por semana" sat 12px to the left of every other
field -- and, more visibly, 12px left of its own label, which stayed
where the others were.

It is a defect in @pi/ui, measured rather than guessed:

  field           has-icon-prefix   prefix   value starts at
  text-field      false             --       16px
  email-field     false             --       16px
  number-field    true              4px      4px

`pi-number-field` declares `<ng-container matPrefix>` unconditionally,
so Material treats the field as having an icon prefix even with nothing
projected into it. The empty 4px prefix displaces the 16px inset the
other fields get. Only two of the library's twenty field templates
declare a prefix, both numeric, and only `incremental-number-field`
actually fills one -- so it is an oversight, not a pattern.

This is the stopgap, marked as such and scoped to that field. The real
fix is already written in the library (`pireon-ui`,
`number-field.component.html`, guarded by a `contentChild`) but is not
built or published yet; this rule comes out when that version lands.

Verified in both themes: all four fields now start their value and their
label at 16px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 19:36:00 +02:00
pigui
aea8f5675d Make the button-shaped links actual links
"Web oficial" did nothing when clicked, and it was not alone: six places
wrapped `<pi-action-button>` in an `<a>`. That component renders a
`<button>`, so each one was a button inside a link -- invalid HTML, and
the button swallows the click. Besides "Web oficial" it covered Demo,
Repositorio, the contact CTA on every detail page, the 404's way home
and a call card's apply button.

They become one `app-link-button`, which renders a real anchor. The
click was only part of it: a link can also be opened in a new tab,
middle-clicked, copied, tabbed to and followed by a crawler.

Its look does not come from @pi/ui's button classes, and not for want of
trying: those rules are scoped to the element (`html button:not(…)` in
`_pi-button.scss`), so on an anchor they contribute nothing except
`pi-color__primary` -- a *text* colour, which painted the label violet on
a violet fill. The stylesheet reads the same `--pi-theme-button-*`
tokens the library reads instead, so a change of shell or theme still
moves these buttons and dark mode needs nothing of its own.

Verified: the anchor reports href, target and a real navigation to the
external site; no `<button>` inside an `<a>` remains on the page; and
the rendered pill matches the header's button in radius, gradient fill,
white text and weight, in both themes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-30 19:07:15 +02:00
pigui
e2e90d5512 Fix lists in posts and pages, and style images inside the text
Every list in a post rendered its bullet alone on one line and the
item's text on the next. The panel's editor wraps each item's text in
a <p>, and @pi/ui's reset puts the bullet inside the line: a block
after an inline marker falls below it, paragraph margins and all.
Bullets now sit outside, with the paragraph inside an item unspaced.

Images in the text — attached in the panel or written in by URL through
the API — are never wider than the column and sit apart from the prose,
with a caption under a figure. Inline code and code blocks get a tinted
surface; quotes keep the library's own quotation marks.

The mock's posts carry such a body, so the typography can be reviewed
without writing one in the panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:11:46 +02:00
pigui
e5d91d673a Put the blog where people look for it
The blog was reachable only from a link in the footer, which is to say
hardly at all. Once there is a post:

- "Blog" joins the header menu, on desktop and in the mobile menu, on
  every page.
- The landing shows the three latest posts under "Novedades", with a
  link to the rest.

The post card moves into its own component, post-card, shared by the
blog (image beside the text) and the landing (image above).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:02:55 +02:00
pigui
5ea9914583 Give every technology a page of its own
A technology's name appeared in nine places -- the chips on project,
call, service and member cards and detail pages, the landing's ring
cards, the radar -- and not one was clickable. Now each leads to
`/tecnologias/:slug`, with the logo, what it is, where the collective
places it on the radar and a link to its own site.

Two constraints from the API shaped this, both verified against it:

Its address is the site's own. Technologies carry no `path` and no
`alternates`, and `/slugs` cannot list them -- its type vocabulary is a
closed enum. `routes.ts` opens by stating the API decides every record's
address; this is the first type where that is not true, so `SECTIONS`
gains the one entry and the comment says why. The sitemap composes these
URLs locally for the same reason, and an unreachable technologies call
there is not fatal the way `/slugs` is: it costs a handful of URLs
rather than misrepresenting the site.

There is no detail endpoint. `GET /technologies/{slug}` answers "route
not found" -- the route does not exist. `pireonApi.technology` filters
the collection instead, passing `in_use: false` because the default
returns only technologies attached to a project, which today is none.
An outage and an unknown slug stay distinguishable so the first is not
reported as a 404.

`website` is the only link the payload has: the project's own home page,
where its documentation lives. There is no separate docs field, so the
label says what the link is.

The chip markup was identical in all nine places, so it becomes one
`app-technology-chip` rather than nine anchors. `project-card` and
`radar-page` gain a `locale` input, which is all they lacked to address
it.

Verified: lint, 54 unit tests, build and 11 e2e. Against the mock, the
three addresses answer 200 and an unknown slug 404; the switcher moves
between them; canonical and hreflang are right; the sitemap lists all
three per technology.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 20:13:05 +02:00
pigui
240ad149e7 Bring the landing to life as it scrolls
- Each section assembles itself on arrival: heading first, then what it
  holds, and a grid's cards one after another — instead of the whole
  section fading in as one block.
- The hero enters on load, piece by piece, in CSS, so it plays with or
  without JavaScript; the stats come last and count up from zero.
- The hero's lights drift and fade as it scrolls away, and the header
  gains a shadow once the page moves — scroll-driven animations, no
  listeners; browsers without them simply do not animate.

The count only runs if the page hydrates before the stats appear at
0.8s: after that the figure has been read, and dropping it to zero to
count again would be a flicker. Server-rendered HTML carries the real
numbers and nothing hidden, and prefers-reduced-motion turns all of it
off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:21:48 +02:00
pigui
fea60a168c Add a blog, built from pages
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>
2026-09-29 15:45:08 +02:00
pigui
86b7485b32 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>
2026-09-29 15:21:59 +02:00
pigui
399b8d5450 Add a page for every way to help
/colabora (/en/contribute, /ca/col-labora) — the calls' own section, so
it lists what /colabora/{slug} are — brings the open calls, the good
first issues across the collective's repositories, and a five-step
guide from picking a task to having it reviewed. With nothing open and
nothing labelled, the guide and the other ways to help are still there.
The landing's "contribute" section links to it.

Also fixes the landing's issue list, which @pi/ui's `li + li` margin
spaced twice over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:14:16 +02:00
pigui
daacc2c5d6 Give each service its own page
A service now has its address, like a project: icon, tagline, what the
collective does and how, the projects it was delivered on, what the
people it was for said, and the technologies behind it. The landing's
service cards link to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:10:19 +02:00
pigui
06eeefc144 Give the tech radar its own page
/radar (/en/radar, /ca/radar) shows every technology the collective has
placed, by ring: logo, category, what it is, why it sits there, its
website and how many projects use it. The landing's radar summary links
to it.

It is the first listing — a page this site composes rather than one
record's — so resolveRoute learns about those (LISTINGS), and builds
their addresses and hreflang alternates from the same table.

Logos sit on a light tile in both themes. A brand's mark is not ours to
recolour, and Rust's, which is black, would vanish in dark mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:07:19 +02:00
pigui
b5681a12ac Give each call its own page, and a form to answer it
A call now has its address — description, what the volunteer gets, the
commitment added up, level, closing date, project, technologies — and
the application form right there. The landing's call card leads to it
instead of scrolling to the general form, and shows the level in the
visitor's language rather than the API's `mid` or `any`.

The form is the landing's, with a `callSlug` that hides the kind
selector and posts to the new /api/calls/{slug}/apply handler: the API
refuses a `kind` on an application, which is always an offer of time.
Both handlers share one check, core/submission.ts, now tested on its
own.

A call that is filled or closed keeps its page — a link to it may
still be out there — but shows why there is no form, and where to
write instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:02:06 +02:00
pigui
439f951f2e Give projects and members their own pages
The API has always served a project's and a member's detail, with
`path` and `alternates` in three languages, and the site had nowhere to
show it. A project now has its page — tagline, status, technologies,
description, technical decisions, what was learned, gallery, client,
dates, lead, team, repositories and open calls — and so does a member:
headline, bio, links, technologies and projects. Cards on the landing
link to both.

Addresses follow the API's rule read backwards (core/routes.ts):
pages at the root, records under a section named in their language,
the default language unprefixed. One catch-all route serves them all.
Not a route per shape: Analog registers each loader as an internal
Nitro route, and Nitro's router cannot hold `/:prefix/:slug` beside
`/:prefix/:section/:slug` — the first became unreachable.

The frame around a record (header, footer, lights, 404/503, title,
description, canonical, hreflang) is one component now, PageShell, and
each type only brings its body, all sharing one layout partial.

Also fixes chips that stepped down one row after the first, on the
landing's cards too: @pi/ui's reset gives every `li + li` a top margin,
right for prose and wrong for a row spaced by `gap`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:57:13 +02:00
pigui
9af6b5aff9 Give the content pages the landing's look
Moving from the landing to a page read as changing sites: a narrow
centred column on a flat background, light headings, no cards. The
page now opens like the landing's hero — the same two lights, the same
left edge as the logo, a bold title with the excerpt as its lead — and
the body's headings are bold like every section title on the landing.

The lights move into a shared mixin (ui/_lights.scss) so the hero and
the pages cannot drift apart. On a page they sit on the whole <main>
with `overflow: clip`, so they fade out on their own instead of ending
in a hard edge right where the text starts, and `clip` rather than
`hidden` keeps the sticky side column working.

A side column keeps a page from being a dead end: "keep reading" cards
for the other pages, its own category first, and a card to get in
touch, both on the landing's card surface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:38:04 +02:00
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
pigui
47c9af682c Remove the build credit from the footer
Drops "Hecho con Angular, Analog y Nx." and the API link beside it,
leaving the copyright line on its own.

`footer.built` goes with it in all three locales. So does `footer.api`,
which was already dead -- declared and translated, never rendered.

The row's flex and space-between existed only to push the credit to the
far end, so they go too.

Verified in es/en/ca: no trace of the credit or the docs link in the
served HTML, and the copyright line renders in each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:37:55 +02:00
pigui
7bb40d32f1 Drop the gradient focus ring, which doubled every field's left edge
The doubled edge was not the textarea's alone -- it was on every field,
and the previous commit's explanation of why the inputs were exempt was
wrong. I asserted that at 999px the ring and the outline clamp to the
same curve without measuring it. They do not:

  ring     999px -> a 24px circle      (half the field's height)
  outline  999px -> a 12x24 ellipse    (leading piece width x half height)

The ring is one continuous rounded rectangle over the whole field; the
outline beneath it is split into three pieces and its leading piece is
12px wide. No radius makes a full-width curve agree with a 12px-wide
one, so there was nothing to tune -- the ring had to go. It was mine,
lifted from the library's `gradient` theme onto a shell that does not
use it, and `alternative` already expresses focus through the outline's
own colour.

The textarea's radius goes back to 1.25rem, since it was only lowered to
dodge the clamp.

Verified at 3x, one curve per field: valid+focus violet, error+focus
red, no ring anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 14:06:59 +02:00
pigui
4d2a8dd26b Fix the doubled edge down the left of the textarea
Focusing the textarea while it had an error drew two red curves down its
left side, a couple of pixels apart. Not a positioning problem -- the
ring and the outline share the same box to the pixel. It is clamping.

The outline's leading piece is 12px wide, so the 20px corner radius it
was given got clamped to 12, while the focus ring, drawn across the full
width, kept a true 20px curve. Same declared radius, two different
curves. At 10px nothing is clamped and they land on top of each other.

The single-line fields never showed this: at 999px both the outline and
the ring clamp to half the field's height, so they already agreed.

Verified at 2x: one continuous outline, ring and leading piece both
reporting 10px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:31:58 +02:00
pigui
3316ac89fd Stop drawing an errored field twice when it takes focus
Focusing a field that already had an error painted two rings at once:
the notched outline stayed in the error colour and the gradient focus
ring went on top of it in the brand's violet-to-cyan. Two rings, two
colours, one field -- and on the textarea at two different radii, which
is what made it visible enough to report.

The ring itself is worth keeping; it is how focus reads everywhere else.
It just has to agree with the state it decorates, so while the field is
invalid it is built from the warn colour. The result is one red
indication that brightens on focus, and valid fields keep the brand
gradient.

Verified in both themes: an errored focused field's ring and outline are
both the warn colour, a valid focused one still gets the brand gradient.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:15:07 +02:00
pigui
216987bb0c Group each error message with the field it belongs to
On the deployed site "Falta el nombre" sat 0px below its own field and
6px above the *next* field's label, so the three lines read as one block
and the message looked like a caption for "HORAS POR SEMANA".

Material renders a field's message flush underneath it, so the space
between rows has to be visibly larger than the space inside one. The
form's row gap goes from 1.25rem to 2rem: 0px to its own field, 18px to
the next label.

Measured against https://pireon.dev before and after. The earlier fixes
in this series are already live there and verified correct on the
deployed build -- labels uppercase at weight 700, the checkbox message
red at 12px on its own line, the textarea's ring at 20px, and no growth
on submit. This one needs a redeploy to show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:04:32 +02:00
pigui
6004110fcf Stop the form moving when it reports errors
Pressing Enviar on an incomplete form pushed everything below each
errored field downwards. Material's subscript strip is 10px tall while a
field is valid and 24px once it carries a message, so the form grew by
14px per field at the moment of the click -- the submit button moved out
from under the cursor. The taller height is now reserved up front.

The consent message had two problems of its own. It rendered as an
inline span straight after the label, so it read as part of the
sentence: "Acepto que guardéis mis datos para responderme. Hace falta tu
consentimiento". And nothing in @pi/ui styles `.pi-checkbox__error`, so
it inherited the body colour and size -- ordinary text sitting beside
four red ones. It now gets its own reserved line, in the warn colour at
the same 12px as every other message.

Verified in both themes: the form grows 0px on submit, and all five
messages match at 12px in the warn colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:51:47 +02:00
pigui
7557894b49 Keep field labels stable on focus, and fix the textarea's ring
Focusing a field visibly reflowed its label: "MENSAJE *" turned into
"Mensaje *" in a lighter face under the cursor. @pi/ui applies the
theme's label typography under `:not(.mdc-text-field--focused)`, so
text-transform, letter-spacing and weight all fall away the moment a
field takes focus. Restated without that guard. Colour is left alone --
Material tints the label on focus and that part is wanted.

The textarea was also drawn twice, once in the error colour and once in
the gradient, the two outlines offset from each other. Both take their
radius from `--pi-theme-field-radius`, which the `alternative` shell
sets to 999px: right for a 48px input, a stadium on a 112px textarea. A
fixed radius makes the two shapes coincide.

Both only appear in states the previous fix did not check -- it verified
the resting form only. Measured now in error+focus: all four labels
uppercase at weight 700, and the textarea's ring and outline both at
20px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 09:08:54 +02:00
pigui
25d0fe500b Fix the hours field's label sitting on the outline
The label read "Horas por semana" struck through by the field's own
border: 5px above the wrapper instead of 18, straddling the 2px outline.

Angular Material measures an outlined field's floating label once on
init and writes the result to the element as an inline transform. This
field is the only one inside an @if, so it is measured before it has any
geometry and keeps the unfloated offset. @pi/ui already publishes the
right values for every other field and its rule matches this one too --
it simply loses to an inline style, so the fix restates the library's
own numbers with `!important`, which is what outranks one.

It also gains a placeholder, which every other field already had.

The e2e config now derives the dev server's port from BASE_URL instead
of pinning 4200. With `reuseExistingServer`, an unrelated project's dev
server on that port gets adopted silently and all five tests fail with
"element(s) not found" while the page under test was never served --
which is exactly what happened while verifying this.

Verified: all four labels now land at -18 with a 48px box, light and
dark; lint, 15 unit tests, build and 5 e2e green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 00:13:12 +02:00
pigui
68ad014df0 Stop publishing a host port
`docker compose up` failed with "port is already allocated": 3000 was
taken by an unrelated project on the same machine. Publishing it was
never needed — in production the only thing that has to reach this
service is the reverse proxy, which does so over the compose network as
`landing:3000`.

`expose` replaces `ports`, and a commented `ports` block stays for the
cases that do want a host binding. LANDING_PORT is kept but re-labelled
as only mattering then.

Verified: the container binds nothing on the host, answers 200 with the
full page from another container on the network, and stays healthy —
its health check was always internal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:59:42 +02:00
pigui
a226cbddbd Fail the image build when the Nexus token is missing
`docker compose build` without NEXUS_TOKEN produced a Nexus 401 forty
seconds in, worded as a credentials problem when it was really an unset
variable. The mount's `required=true` asserts the secret was *declared*,
not that it holds anything, so an empty one sailed past it and npm sent
an empty `_authToken`.

The install moves to tools/docker-install-deps.sh, which checks the
secret before touching the network and fails in under a second with the
three ways to supply it. It is a file rather than inline shell because
BuildKit echoes an inline command twice on failure and buries the
message.

Also ignore .env. It carries NEXUS_TOKEN, compose reads it at build
time, and it was not gitignored — .env.example stays tracked. Nothing
had been committed: the only prior commit predates all of this.

Verified: empty token fails in 0.2s with the message; .env and the
environment variable both build; the image carries no trace of the
token and serves the full page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:56:11 +02:00
93 changed files with 11969 additions and 309 deletions

View File

@@ -11,6 +11,9 @@ tmp
.claude
docs
landing-e2e
# tools/ IS needed: the build stage copies docker-install-deps.sh from it.
# The mock API servers in there are dev-only but too small to be worth excluding.
**/*.spec.ts
test-output
.env

View File

@@ -1,10 +1,21 @@
# Base URL of the Pireon API, without a trailing slash. Read on the server
# only: the browser never talks to the API directly.
# Copy to `.env`. Compose reads it on its own — for the runtime settings below
# and for the build-time token. `.env` is gitignored; this file is not.
# Base URL of the Pireon API, without a trailing slash. Read on the server only:
# the browser never talks to the API directly.
PIREON_API_URL=https://landing-api.pireon.dev/api
# Host port the container is published on.
# Only used if you uncomment `ports` in docker-compose.yml. By default the
# container is not published on the host at all: the reverse proxy reaches it
# over the compose network as `landing:3000`, and publishing a host port only
# invites a collision with whatever else is on that number.
LANDING_PORT=3000
# Only needed to *build* the image: `@pi/ui` comes from the private Nexus.
# Not read at runtime, and never baked into a layer — see Dockerfile.
# Needed only to *build* the image: `@pi/ui` comes from the private Nexus.
# Locally this is the value already sitting in ~/.npmrc:
#
# grep nexus.pireon.dev ~/.npmrc
#
# Never baked into a layer — it is mounted as a BuildKit secret. An empty value
# fails the build immediately with an explanation rather than a Nexus 401.
NEXUS_TOKEN=

10
.gitignore vendored
View File

@@ -46,3 +46,13 @@ Thumbs.db
.nx/polygraph
.angular
# Secrets. `.env` holds NEXUS_TOKEN, which compose reads at build time, so it
# must never be committed — `.env.example` is the one that is.
.env
.env.*
!.env.example
# A per-machine npmrc would carry a Nexus token; the committed one at the repo
# root deliberately holds none.
.npmrc.local

View File

@@ -19,14 +19,12 @@ COPY package.json package-lock.json .npmrc ./
# `@pi/ui` comes from the private Nexus, so the install needs a token — passed
# as a BuildKit secret rather than a build ARG, which would be readable forever
# in the image history. The npmrc it lands in is written and removed inside one
# layer, so it leaves no trace even in this discarded stage.
# in the image history. The script checks the secret actually holds something
# before spending four minutes finding out from Nexus.
COPY tools/docker-install-deps.sh ./tools/
RUN --mount=type=secret,id=nexus_token,required=true \
sh -eu -c '\
printf "//nexus.pireon.dev/repository/npm-releases/:_authToken=%s\n" \
"$(cat /run/secrets/nexus_token)" > /tmp/npmrc; \
NPM_CONFIG_USERCONFIG=/tmp/npmrc npm ci --no-audit --no-fund; \
rm -f /tmp/npmrc'
sh tools/docker-install-deps.sh
COPY nx.json tsconfig.base.json eslint.config.mjs ./
COPY landing ./landing

163
README.md
View File

@@ -18,10 +18,25 @@ npm run e2e # Playwright end-to-end tests
## Deploying
```sh
NEXUS_TOKEN=… docker compose build
cp .env.example .env # then fill in NEXUS_TOKEN
docker compose build
docker compose up -d
```
Compose reads `.env` on its own, for the build token as well as the runtime
settings. `.env` is gitignored; `.env.example` is the one that is committed. If
you would rather not keep the token on disk:
```sh
NEXUS_TOKEN=… docker compose build
```
An empty or missing token fails the build in under a second with a message
saying so. That check is there because the mount's own `required=true` only
asserts the secret was *declared* — an empty one passes it and then surfaces a
minute later as a Nexus 401 that reads like a credentials problem when it is
really a forgotten variable.
Two stages. The build one runs Nx and the Angular compiler over the whole
workspace and is thrown away; only `dist/landing/analog` crosses into the
runtime image, because Nitro vendors its own dependencies into that output —
@@ -56,15 +71,20 @@ API were in maintenance. `core/api.ts` gives each call an 8-second budget, which
is what stands between a slow upstream and a hung render.
The container runs with a read-only filesystem and `no-new-privileges`, with a
tmpfs at `/tmp` for Nitro's scratch space. Put a reverse proxy in front for TLS
and let it pass response headers through untouched: the app answers 503 with
tmpfs at `/tmp` for Nitro's scratch space. The container publishes no host port: the reverse proxy reaches it over the
compose network as `landing:3000`, and binding a host port only invites a
collision with whatever else is on that number. `docker-compose.yml` carries a
commented `ports` block for when you do want one.
Put that proxy in front for TLS and let it pass response headers through
untouched: the app answers 503 with
`Retry-After` during maintenance and 404 for addresses it does not serve, and
both matter to crawlers and CDNs.
| Variable | Where | Default |
| --- | --- | --- |
| `PIREON_API_URL` | runtime | `https://landing-api.pireon.dev/api` |
| `LANDING_PORT` | runtime, host side | `3000` |
| `LANDING_PORT` | only if you publish a host port | `3000` |
| `NEXUS_TOKEN` | **build only** | — |
Copy `.env.example` to `.env`; compose reads it.
@@ -111,6 +131,15 @@ pass on the server:
| Charlas | `/talks?limit=6` |
| Contact form | `POST /contact` |
A project page also asks TaskStream, when the project has a board there
(`taskstream.showcase_url` in its payload): roadmap (with how many want each
epic), releases, the sprint under way, tickets labelled `good-first-issue`, and
the most voted bug reports and ideas from the public, with a link to send one
while the board takes them. It is public, answers within four seconds or the
page goes without those sections, and its links open the board (voting needs
signing in there). `npm run mock` serves a showcase for the «Catálogo abierto»
project, so every section can be seen without TaskStream.
`landing/openapi.yaml` is the specification the app was written against, and
`landing/src/app/core/api.types.ts` is the API's own generated type file. Both
are copies — refresh them from the API when the contract moves:
@@ -130,18 +159,32 @@ landing/src/
core/copy.ts the chrome's own words, in es / en / ca
core/icons.ts the icon set, inlined and registered as literals
core/pi-strings.ts the strings `@pi/ui` translates on its own
pages/(home).server.ts one load function, ten parallel calls
core/routes.ts which record an address belongs to (resolveRoute)
core/routed-page.ts the load function for every address but the landing
pages/(home).server.ts one load function, eleven parallel calls
pages/(home).page.ts the landing itself
pages/[...path].page.ts everything else: pages, projects, members, 404
ui/page-shell.ts header, footer, lights, 404/503 and SEO around a record
ui/routed-page.ts picks the body for the record's type
ui/ presentational components, one per payload type
(thin wrappers over `@pi/ui`), each with its `.scss`
server/
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/sitemap.xml.get.ts every published address, from the API's /slugs
routes/robots.txt.get.ts points crawlers at the sitemap
routes/blog/rss.xml.get.ts the blog as RSS, one feed per language
middleware/redirects.ts 301s for slugs renamed in the panel
middleware/not-found-status.ts
styles.scss Material theme, `@pi/ui` sheets, shared primitives
```
**Nothing renders from the browser.** All reads happen in the page's `load`
function, and the one write goes through this app's own `/api/contact` handler.
function, and the two writes go through this app's own handlers —
`/api/contact` for the general form, `/api/calls/{slug}/apply` for a call's —
which share one check (`core/submission.ts`) before spending a round trip. An
application to a call sends no `kind`: the API refuses one there, because
answering a call is always an offer of time.
That is what the API expects: it throttles submissions per e-mail address
rather than per IP *because* an SSR frontend posts on the visitor's behalf, and
keeping the API host out of the browser means CORS never comes up.
@@ -162,7 +205,14 @@ cached maintenance page outlives the maintenance. That status is set from the
page component through `injectResponse()` (`@analogjs/router/tokens`), which is
the *render* request's response; the one a page `load` sees belongs to the
internal request Analog resolves loaders through and never reaches the browser.
That is also why the 404 status comes from middleware instead.
That is also why the 404 status comes from middleware instead — except for
addresses shaped like a record, where only the API knows whether it exists: the
middleware lets those through and `ui/page-shell.ts` sets the 404 itself from
`ngOnInit`, once its input has arrived.
**`@pi/ui`'s reset spaces every `li + li`** with a top margin — right for a
list of prose, wrong for a row of chips laid out with `gap`, where it dropped
every chip but the first. Lists spaced by `gap` reset it on their own `> li`.
**Empty sections disappear**, along with their entry in the nav — a young site
shows a hero and a contact form rather than eight headings over nothing.
@@ -176,6 +226,85 @@ page cached with `s-maxage` that varied on `Accept-Language` would hand the
second visitor the wrong language anyway. The switcher in the header is three
plain links, and `<html lang>` follows.
**Records are the exception**, because the API decides their address: a
record's `path` and `alternates` put the default language unprefixed and every
other one under its code, pages at the root and everything else under a section
named in that language:
```
/sobre-nosotros /en/about-us /ca/qui-som
/proyectos/catalogo /en/projects/catalogo /ca/projectes/catalogo
/equipo/ada /en/team/ada /ca/equip/ada
/colabora/frontend /en/contribute/frontend /ca/col-labora/frontend
/servicios/formacion /en/services/formacion /ca/serveis/formacion
```
Besides records there are **listings**, pages this site composes itself —
the tech radar at `/radar`, `/en/radar`, `/ca/radar`, and the ways to help at
`/colabora`, `/en/contribute`, `/ca/col-labora` — the calls' own section, so it
lists what `/colabora/{slug}` are, next to good first issues and a guide to a
first contribution (`LISTINGS`). A panel page
with the same slug would be shadowed by one, so they are few and deliberately
named. Radar logos sit on a light tile in both themes: a brand's mark is not
ours to recolour, and some (Rust's, black) would vanish in dark mode.
`core/routes.ts#resolveRoute` is that rule read backwards, and its `SECTIONS`
mirror the API's `config/site.php` (`paths`) — a change there has to be made
here too. `/es/…` is not a shape the API produces, so a record has one address
rather than two. The switcher on a record follows `alternates`, and a language
it has no translation for leads to the landing in that language instead of a
404. The same `alternates` become `hreflang` links, with a `canonical`.
**One catch-all route** (`pages/[...path]`) serves every address but the
landing, and `resolveRoute` sorts the shapes out. Not one route per shape:
Analog registers each loader as an internal Nitro route, and Nitro's router
cannot hold two parameters of different names at the same depth —
`/:prefix/:slug` beside `/:prefix/:section/:slug` made the first unreachable.
Featured pages go in the header after the section anchors; every page is in the
footer.
## The blog
Not a type of its own: **a post is a page in the panel's "Blog" category** —
written, translated, scheduled, reviewed and reachable by the MCP server like
any page. That category has to exist with the slug `blog` in every language
(`core/blog.ts`), because the API filters by the slug in the language asked for.
- `/blog` (`/en/blog`, `/ca/blog`) lists them newest first.
- A post shows its date and leads back to the blog; its structured data is a
`BlogPosting`.
- A post shows who signs it (`byline`, linked to their profile) and its tags,
each linking to `/blog?tag=…`. The blog offers the tags in use as a row of
links and narrows to one with `?tag=` — plain links, so a tag's listing can
be shared and works before any script runs. Tags and bylines are set in the
panel.
- `/blog/rss.xml?lang=xx` is the feed, one per language, announced in `<head>`.
- Once there is a post, the blog is in the header menu (desktop and mobile)
and the landing shows the three latest under "Novedades"; the footer links
it too, but does not list posts one by one — it would grow without end.
A blog reachable only from the footer turned out to be one nobody found.
## 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`, `/robots.txt`
and `/blog/rss.xml` answer 404 under `npm start`; build and run `npm run preview` to
see them. Middleware does run in development.
## Rendering and caching
Nothing is prerendered: the page is assembled from content the panel publishes
@@ -311,11 +440,21 @@ Two pieces, both of which have to survive a page that renders on the server:
so the arrows have to be asked for, and the controls only appear at all once
there are more items than fit — with two testimonials it degrades to a plain
row, which is the right answer.
- **`ui/reveal.ts`** fades each section in as it arrives. The hidden state is
*not* in the server-rendered HTML: it only applies under `html.reveal-ready`,
a class the directive adds at runtime, so a visitor without JavaScript reads a
full page instead of a blank column. `prefers-reduced-motion` skips the
observer entirely.
- **`ui/reveal.ts`** assembles each section as it arrives: its heading first,
then what it holds, and a grid's cards one after another (up to eight, then
together). The hidden state is *not* in the server-rendered HTML: it only
applies under `html.reveal-ready`, a class the directive adds at runtime, so
a visitor without JavaScript reads a full page instead of a blank column.
- **The hero enters on load**, in CSS (`hero.scss`), so it plays with or
without JavaScript: eyebrow, title, lead, buttons, and the stats last, at
0.8s. The stats count up from zero — but only if the page hydrates before
then (`COUNT_UP_BEFORE_MS` in `stat-grid.ts`). Later than that the number has
already been read, and dropping it to zero to count again would be a flicker.
- **Scroll-driven, no JavaScript**: the hero's lights drift and fade as it
scrolls away, and the header gains a shadow once the page moves
(`animation-timeline: scroll()`). Browsers without it simply do not animate.
`prefers-reduced-motion` turns every one of these off.
`PiThemeService` owns the shell class, pinned to `alternative` in
`app.config.ts` — the library's marketing look: pill buttons with a gradient

View File

@@ -24,8 +24,20 @@ services:
- nexus_token
image: pireon/landing:latest
restart: unless-stopped
ports:
- '${LANDING_PORT:-3000}:3000'
# No `ports`. Publishing on the host collides with whatever else is using
# that number on the machine, and in production the only thing that needs
# to reach this is the reverse proxy — which does so over the compose
# network, by service name, on the port below.
#
# proxy_pass http://landing:3000;
#
# To reach it from the host anyway (a quick check, or a deployment with no
# proxy in front), uncomment this and set LANDING_PORT to a free port:
#
# ports:
# - '${LANDING_PORT:-3000}:3000'
expose:
- '3000'
environment:
# Where the public API lives. The only setting the app actually reads.
PIREON_API_URL: ${PIREON_API_URL:-https://landing-api.pireon.dev/api}

View File

@@ -12,6 +12,19 @@ const here = dirname(fileURLToPath(import.meta.url));
const workspaceRoot = resolve(here, '..');
const baseURL = process.env['BASE_URL'] || 'http://localhost:4200';
/**
* The dev server is started on whatever port `BASE_URL` names, rather than
* always 4200.
*
* `reuseExistingServer` means Playwright will happily adopt whatever is already
* listening there — and if that is a different project's dev server, every test
* fails with "element(s) not found" while the real cause is that the page under
* test was never served. Set BASE_URL to move both halves at once:
*
* BASE_URL=http://localhost:4280 npx nx e2e landing-e2e
*/
const port = new URL(baseURL).port || '4200';
export default defineConfig({
testDir: './src',
outputDir: resolve(workspaceRoot, 'dist/.playwright/landing-e2e/test-output'),
@@ -25,7 +38,7 @@ export default defineConfig({
trace: 'on-first-retry',
},
webServer: {
command: 'npx nx run landing:serve',
command: `npx nx run landing:serve --port=${port}`,
url: baseURL,
reuseExistingServer: true,
timeout: 180_000,

View File

@@ -43,6 +43,80 @@ test.describe('landing', () => {
await expect(page.locator('h1')).toContainText('no existe');
});
test('answers 404 for a page the API does not have', async ({ page }) => {
// Shaped like a content page, so the middleware lets it through and the
// status has to come from the page component, once the API said no.
const response = await page.goto('/en/no-such-page');
expect(response?.status()).toBe(404);
await expect(page.locator('h1')).toContainText('no existe');
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
'content',
'noindex',
);
});
test('answers 404 for a record the API does not have, in any section', async ({
page,
}) => {
for (const path of [
'/proyectos/no-existe',
'/en/team/nobody',
'/ca/projectes/res',
]) {
const response = await page.goto(path);
expect(response?.status(), path).toBe(404);
await expect(page.locator('h1')).toContainText('no existe');
}
});
test('answers 404 for a section spelled in another language', async ({
page,
}) => {
// `/projects/x` is the English section without the English prefix: not an
// address the API ever hands out.
const response = await page.goto('/projects/anything');
expect(response?.status()).toBe(404);
});
test('serves the radar in every language, and not under /es', async ({
page,
}) => {
for (const [path, heading] of [
['/radar', 'Radar técnico'],
['/en/radar', 'Tech radar'],
['/ca/radar', 'Radar tècnic'],
]) {
const response = await page.goto(path);
expect(response?.status(), path).toBe(200);
await expect(page.locator('h1')).toContainText(heading);
}
expect((await page.goto('/es/radar'))?.status()).toBe(404);
});
test("lists the ways to help on the calls' own section", async ({ page }) => {
for (const [path, heading] of [
['/colabora', 'Tu primera contribución'],
['/en/contribute', 'Your first contribution'],
]) {
expect((await page.goto(path))?.status(), path).toBe(200);
await expect(page.locator('h1')).toContainText(heading);
}
});
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,
}) => {

File diff suppressed because it is too large Load Diff

View File

@@ -28,3 +28,4 @@ describe('localeFromUrl', () => {
expect(localeFromUrl(undefined)).toBe(DEFAULT_LOCALE);
});
});

View File

@@ -12,6 +12,7 @@ import type {
PublicClient,
PublicIssue,
PublicMember,
PublicPage,
PublicProject,
PublicService,
PublicTalk,
@@ -91,6 +92,8 @@ export interface ApiResult<T> {
data: T | null;
maintenance: MaintenanceNotice | null;
error: string | null;
/** The HTTP status, when there was a response — a 404 means something else than an outage. */
status: number | null;
}
interface GetOptions {
@@ -137,6 +140,7 @@ async function get<T>(path: string, options: GetOptions = {}): Promise<ApiResult
return {
data: null,
error: null,
status: 503,
maintenance: {
message: body?.message ?? 'El sitio está en mantenimiento.',
maintenance: true,
@@ -149,16 +153,26 @@ async function get<T>(path: string, options: GetOptions = {}): Promise<ApiResult
}
if (!response.ok) {
return { data: null, maintenance: null, error: `HTTP ${response.status}` };
return {
data: null,
maintenance: null,
error: `HTTP ${response.status}`,
status: response.status,
};
}
const body = (await response.json()) as { data: T };
return { data: body.data ?? null, maintenance: null, error: null };
return {
data: body.data ?? null,
maintenance: null,
error: null,
status: response.status,
};
} catch (cause) {
const error = cause instanceof Error ? cause.message : 'Unknown error';
return { data: null, maintenance: null, error };
return { data: null, maintenance: null, error, status: null };
} finally {
clearTimeout(timer);
}
@@ -171,7 +185,28 @@ async function list<T>(path: string, options: GetOptions = {}): Promise<ApiResul
return { ...result, data: result.data ?? [] };
}
/** One published record in `/slugs`: its address in every language it has. */
export interface SlugEntry {
id: number;
updated_at?: string;
alternates: Record<string, string>;
}
export interface Redirect {
from: string;
to: string;
status: 301 | 302;
/** Null: applies whatever language the visitor is browsing in. */
locale?: string | null;
}
export const pireonApi = {
/** Every published address, grouped by type. What the sitemap is built from. */
slugs: () => get<Record<string, SlugEntry[]>>('/slugs'),
/** Addresses that used to work and where they go now. Whole: there are never many. */
redirects: () => list<Redirect>('/redirects', { timeout: 3000 }),
site: (lang: Locale) => get<SitePayload>('/site', { lang }),
services: (lang: Locale, query?: GetOptions['query']) =>
@@ -203,8 +238,60 @@ export const pireonApi = {
technologies: (lang: Locale, query?: GetOptions['query']) =>
list<PublicTechnology>('/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<ApiResult<PublicTechnology>> => {
const all = await list<PublicTechnology>('/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<PublicIssue>('/good-first-issues', { query }),
pages: (lang: Locale, query?: GetOptions['query']) =>
list<PublicPage>('/pages', { lang, query }),
/** By slug, which only resolves in the language it belongs to. */
page: (lang: Locale, slug: string) =>
get<PublicPage>(`/pages/${encodeURIComponent(slug)}`, { lang }),
project: (lang: Locale, slug: string) =>
get<PublicProject>(`/projects/${encodeURIComponent(slug)}`, { lang }),
member: (lang: Locale, slug: string) =>
get<PublicMember>(`/members/${encodeURIComponent(slug)}`, { lang }),
service: (lang: Locale, slug: string) =>
get<PublicService>(`/services/${encodeURIComponent(slug)}`, { lang }),
call: (lang: Locale, slug: string) =>
get<PublicCall>(`/calls/${encodeURIComponent(slug)}`, { lang }),
} as const;
/** POST bodies the public API accepts. Only two endpoints write. */

View File

@@ -165,6 +165,14 @@ export interface PublicPage {
unpublish_at?: string;
updated_at: string;
category: PublicCategory;
/**
* The ones named in this language, to link to the blog filtered by each.
*/
tags: ({ name: string; slug: string })[];
/**
* The member who signs it, when they have a public profile.
*/
byline?: { name: string; slug: string; headline?: string; avatar?: Image; path: string };
alternates: Alternates;
path?: string;
}
@@ -233,6 +241,10 @@ export interface PublicProject {
lead: PublicMember;
calls: PublicCall[];
repositories: PublicRepository[];
/**
* Its board in TaskStream, if it has one. `showcase_url` answers without a token: roadmap, releases, the sprint under way and tickets labelled good-first-issue.
*/
taskstream?: { key: string; url: string; web_url: string; showcase_url: string };
alternates: Alternates;
path?: string;
}
@@ -268,6 +280,21 @@ export interface PublicRepository {
good_first_issues: PublicIssue[];
}
export interface PublicSearchResult {
type: 'page' | 'project' | 'service' | 'call' | 'member' | 'technology';
id: number;
title: string;
/**
* Plain text around the first match.
*/
excerpt: string;
path?: string;
/**
* Higher is a better match. Only comparable within one response.
*/
score: number;
}
export interface PublicService {
id: number;
locale: string;
@@ -382,6 +409,8 @@ export interface PublicApiPayloads {
'/public/v1/site': { locales: ({ code: string; name: string; flag: string; is_default: boolean })[]; default_locale: string; content_version: number; submissions_enabled: boolean; stats: { projects: number; active_projects: number; members: number; open_calls: number; good_first_issues: number; stars: number } };
'/public/v1/slugs': Record<string, ({ id: number; updated_at: string; alternates: Record<string, string> })[]>;
'/public/v1/redirects': ({ from: string; to: string; status: 301 | 302; locale?: string })[];
'/public/v1/search': PublicSearchResult[];
'/public/v1/tags': ({ name: string; slug: string; count: number })[];
'/public/v1/projects': PublicProject[];
'/public/v1/projects/{slug}': PublicProject;
'/public/v1/members': PublicMember[];

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import type { PublicPage } from './api.types';
import { tagHref, taggedWith, tagsOf } from './blog';
const post = (id: number, tags: { name: string; slug: string }[]) =>
({ id, tags }) as unknown as PublicPage;
describe('blog tags', () => {
const posts = [
post(1, [{ name: 'Angular', slug: 'angular' }]),
post(2, [
{ name: 'Angular', slug: 'angular' },
{ name: 'Accesibilidad', slug: 'accesibilidad' },
]),
post(3, []),
];
it('lists each tag once, most used first', () => {
expect(tagsOf(posts)).toEqual([
{ name: 'Angular', slug: 'angular', count: 2 },
{ name: 'Accesibilidad', slug: 'accesibilidad', count: 1 },
]);
});
it('narrows the posts to a tag, or keeps them all', () => {
expect(taggedWith(posts, 'accesibilidad').map((p) => p.id)).toEqual([2]);
expect(taggedWith(posts, null)).toHaveLength(3);
});
it('links to the blog in each language', () => {
expect(tagHref('es', 'angular')).toBe('/blog?tag=angular');
expect(tagHref('en', 'a b')).toBe('/en/blog?tag=a%20b');
});
});

View File

@@ -0,0 +1,69 @@
/**
* The blog is not a type of its own: a post is a page in the panel's "Blog"
* category, written, translated, scheduled and reviewed like any other page,
* and reachable by the MCP server the same way.
*
* That category has to exist with this slug in every language — the API
* filters by the slug in the language asked for.
*/
import type { Locale } from './api';
import type { PublicPage } from './api.types';
import { listingPath } from './routes';
export const BLOG_CATEGORY: Record<Locale, string> = {
es: 'blog',
en: 'blog',
ca: 'blog',
};
export function isBlogPost(page: PublicPage, locale: Locale): boolean {
return page.category?.slug === BLOG_CATEGORY[locale];
}
/** Newest first, whatever order they arrived in. */
export function byPublication(pages: PublicPage[]): PublicPage[] {
return [...pages].sort((a, b) =>
(b.published_at ?? '').localeCompare(a.published_at ?? ''),
);
}
export interface BlogTag {
name: string;
slug: string;
count: number;
}
/** The blog, showing only the posts with this tag. */
export function tagHref(locale: Locale, slug: string): string {
return `${listingPath('blog', locale)}?tag=${encodeURIComponent(slug)}`;
}
/** Every tag on these posts, most used first, then by name. */
export function tagsOf(posts: PublicPage[]): BlogTag[] {
const tags = new Map<string, BlogTag>();
for (const post of posts) {
for (const tag of post.tags ?? []) {
const known = tags.get(tag.slug);
tags.set(tag.slug, {
name: tag.name,
slug: tag.slug,
count: (known?.count ?? 0) + 1,
});
}
}
return [...tags.values()].sort(
(a, b) => b.count - a.count || a.name.localeCompare(b.name),
);
}
/** The posts carrying a tag, or all of them when there is none. */
export function taggedWith(
posts: PublicPage[],
tag: string | null,
): PublicPage[] {
return tag
? posts.filter((post) => (post.tags ?? []).some((t) => t.slug === tag))
: posts;
}

View File

@@ -78,6 +78,7 @@ export interface Copy {
email: string;
organisation: string;
hours: string;
hoursHint: string;
message: string;
messageHint: string;
consent: string;
@@ -88,7 +89,114 @@ export interface Copy {
error: string;
closed: string;
};
footer: { built: string; api: string; rights: string };
footer: { rights: string; pages: string };
page: { skip: string; related: string; ctaTitle: string; ctaLead: string };
project: {
decisions: string;
postmortem: string;
gallery: string;
details: string;
status: string;
client: string;
period: string;
lead: string;
team: string;
repositories: string;
forks: string;
dormant: string;
archived: string;
openCalls: string;
technologies: string;
roadmap: string;
roadmapLead: string;
releases: string;
released: string;
planned: string;
sprint: string;
goodFirst: string;
goodFirstLead: string;
board: string;
done: string;
proposals: string;
proposalsLead: string;
propose: string;
voteOne: string;
voteMany: string;
};
member: {
projects: string;
technologies: string;
links: string;
since: string;
};
contribute: {
eyebrow: string;
title: string;
lead: string;
calls: string;
callsLead: string;
issues: string;
issuesLead: string;
noIssues: string;
guide: string;
steps: { title: string; text: string }[];
otherWays: string;
otherWaysLead: string;
};
blog: {
eyebrow: string;
title: string;
lead: string;
empty: string;
readMore: string;
published: string;
feed: string;
seeAll: string;
by: string;
tags: string;
allTags: string;
tagged: string;
noneTagged: string;
};
service: {
eyebrow: string;
projects: string;
testimonials: string;
technologies: string;
};
technology: {
eyebrow: string;
ringTitle: string;
website: string;
usedIn: string;
membersUsing: string;
back: string;
};
radar: {
eyebrow: string;
lead: string;
ringLead: Record<'adopt' | 'trial' | 'assess' | 'hold', string>;
categories: Record<
'language' | 'framework' | 'datastore' | 'infrastructure' | 'tool' | 'practice',
string
>;
website: string;
projects: string;
empty: string;
seeAll: string;
};
call: {
eyebrow: string;
seniority: Record<'any' | 'junior' | 'mid' | 'senior', string>;
whatYouGet: string;
commitment: string;
totalHours: string;
project: string;
apply: string;
applyLead: string;
unavailable: string;
unavailableLead: string;
};
maintenance: { title: string; back: string; until: string };
}
@@ -167,6 +275,7 @@ const es: Copy = {
email: 'Correo',
organisation: 'Organización',
hours: 'Horas por semana',
hoursHint: 'Por ejemplo, 4',
message: 'Mensaje',
messageHint: 'Mínimo 20 caracteres.',
consent: 'Acepto que guardéis mis datos para responderme.',
@@ -178,9 +287,135 @@ const es: Copy = {
closed: 'El formulario está cerrado temporalmente.',
},
footer: {
built: 'Hecho con Angular, Analog y Nx.',
api: 'Contenido servido por la API pública de Pireon.',
rights: 'Todos los derechos reservados.',
pages: 'Más sobre Pireon',
},
page: {
skip: 'Saltar al contenido',
related: 'Sigue leyendo',
ctaTitle: '¿Hablamos?',
ctaLead: 'Si algo de esto encaja con lo que buscas, escríbenos y te respondemos.',
},
project: {
decisions: 'Decisiones técnicas',
postmortem: 'Qué aprendimos',
gallery: 'Galería',
details: 'El proyecto',
status: 'Estado',
client: 'Para',
period: 'Fechas',
lead: 'Responsable',
team: 'Equipo',
repositories: 'Código',
forks: 'forks',
dormant: 'Sin actividad reciente',
archived: 'Archivado',
openCalls: 'Buscamos ayuda',
technologies: 'Tecnologías',
roadmap: 'Hoja de ruta',
roadmapLead: 'Lo que viene, según nuestro tablero.',
releases: 'Versiones',
released: 'Publicada',
planned: 'Prevista',
sprint: 'Ahora mismo',
goodFirst: 'Para empezar a colaborar',
goodFirstLead: 'Tareas pensadas para quien llega nuevo.',
board: 'Ver el tablero',
done: 'Hecho',
proposals: 'Ideas y errores',
proposalsLead: 'Lo que más se pide. Para votar, entra en el tablero.',
propose: 'Proponer una idea o avisar de un error',
voteOne: '1 voto',
voteMany: '{n} votos',
},
member: {
projects: 'Proyectos',
technologies: 'Tecnologías',
links: 'Enlaces',
since: 'En el colectivo desde',
},
contribute: {
eyebrow: 'Colabora',
title: 'Tu primera contribución',
lead: 'No hace falta ser experta ni tener mucho tiempo. Elige algo pequeño, pregunta lo que necesites y te acompañamos hasta que tu cambio esté publicado.',
calls: 'Convocatorias abiertas',
callsLead: 'Proyectos que buscan manos ahora mismo, con la dedicación semanal clara.',
issues: 'Tareas para empezar',
issuesLead: 'Issues de nuestros repositorios pensadas para quien llega por primera vez.',
noIssues: 'Ahora mismo no hay tareas etiquetadas para empezar. Escríbenos y te buscamos una.',
guide: 'Cómo empezar',
steps: [
{ title: 'Elige una tarea', text: 'Busca una etiquetada como «good first issue»: son acotadas y están bien explicadas.' },
{ title: 'Dilo en la issue', text: 'Deja un comentario para que nadie trabaje en lo mismo. Si tienes dudas, pregúntalas ahí.' },
{ title: 'Haz tu cambio', text: 'Crea una rama desde la principal, haz el cambio y comprueba que los tests pasan.' },
{ title: 'Abre una pull request', text: 'Explica qué has cambiado y por qué. Enlaza la issue para que se cierre sola.' },
{ title: 'Revisión', text: 'Alguien del colectivo revisará tu código contigo. Es una conversación, no un examen.' },
],
otherWays: 'Otras formas de ayudar',
otherWaysLead: 'También necesitamos diseño, documentación, traducciones y gente que pruebe lo que hacemos. Escríbenos y lo hablamos.',
},
blog: {
eyebrow: 'Blog',
title: 'Novedades',
lead: 'Lo que estamos haciendo, lo que hemos aprendido y lo que viene.',
empty: 'Todavía no hemos publicado nada. Vuelve pronto.',
readMore: 'Leer',
published: 'Publicado el',
feed: 'Suscríbete por RSS',
seeAll: 'Ver todas las novedades',
by: 'Por',
tags: 'Temas',
allTags: 'Todo',
tagged: 'Entradas sobre «{tag}»',
noneTagged: 'No hay entradas con esta etiqueta.',
},
service: {
eyebrow: 'Servicio',
projects: 'Trabajo relacionado',
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.',
ringLead: {
adopt: 'Lo usamos en proyectos reales y lo recomendamos sin reservas.',
trial: 'Lo estamos probando en proyectos donde el riesgo es asumible.',
assess: 'Nos interesa y lo estamos estudiando, sin usarlo aún en serio.',
hold: 'Por ahora preferimos no empezar nada nuevo con esto.',
},
categories: {
language: 'Lenguaje',
framework: 'Framework',
datastore: 'Datos',
infrastructure: 'Infraestructura',
tool: 'Herramienta',
practice: 'Práctica',
},
website: 'Web oficial',
projects: 'proyectos',
empty: 'Todavía no hemos colocado ninguna tecnología en el radar.',
seeAll: 'Ver el radar completo',
},
call: {
eyebrow: 'Convocatoria',
seniority: { any: 'Cualquier nivel', junior: 'Junior', mid: 'Intermedio', senior: 'Senior' },
whatYouGet: 'Qué te llevas',
commitment: 'Dedicación',
totalHours: 'horas en total',
project: 'Proyecto',
apply: 'Apúntate',
applyLead: 'Cuéntanos quién eres y qué te gustaría hacer. Te respondemos personas, no un bot.',
unavailable: 'Esta convocatoria ya no está abierta',
unavailableLead: 'Si te interesa colaborar igualmente, escríbenos desde el formulario general.',
},
maintenance: {
title: 'Estamos de mantenimiento',
@@ -264,6 +499,7 @@ const en: Copy = {
email: 'E-mail',
organisation: 'Organisation',
hours: 'Hours per week',
hoursHint: 'For example, 4',
message: 'Message',
messageHint: 'At least 20 characters.',
consent: 'I agree to have my details stored so you can reply.',
@@ -275,9 +511,135 @@ const en: Copy = {
closed: 'The form is temporarily closed.',
},
footer: {
built: 'Built with Angular, Analog and Nx.',
api: 'Content served by the Pireon public API.',
rights: 'All rights reserved.',
pages: 'More about Pireon',
},
page: {
skip: 'Skip to content',
related: 'Keep reading',
ctaTitle: 'Shall we talk?',
ctaLead: 'If any of this fits what you are looking for, write to us and we will get back to you.',
},
project: {
decisions: 'Technical decisions',
postmortem: 'What we learned',
gallery: 'Gallery',
details: 'The project',
status: 'Status',
client: 'For',
period: 'Dates',
lead: 'Lead',
team: 'Team',
repositories: 'Code',
forks: 'forks',
dormant: 'No recent activity',
archived: 'Archived',
openCalls: 'Looking for help',
technologies: 'Technologies',
roadmap: 'Roadmap',
roadmapLead: 'What is coming, from our board.',
releases: 'Releases',
released: 'Released',
planned: 'Planned',
sprint: 'Right now',
goodFirst: 'Good first issues',
goodFirstLead: 'Tasks meant for newcomers.',
board: 'See the board',
done: 'Done',
proposals: 'Ideas and bugs',
proposalsLead: 'What people ask for most. To vote, sign in on the board.',
propose: 'Suggest an idea or report a bug',
voteOne: '1 vote',
voteMany: '{n} votes',
},
member: {
projects: 'Projects',
technologies: 'Technologies',
links: 'Links',
since: 'In the collective since',
},
contribute: {
eyebrow: 'Contribute',
title: 'Your first contribution',
lead: 'You do not need to be an expert or have lots of time. Pick something small, ask whatever you need and we will help you all the way until your change is live.',
calls: 'Open calls',
callsLead: 'Projects looking for hands right now, with the weekly commitment stated up front.',
issues: 'Good first issues',
issuesLead: 'Issues in our repositories meant for people contributing for the first time.',
noIssues: 'There are no issues labelled for newcomers right now. Write to us and we will find you one.',
guide: 'How to get started',
steps: [
{ title: 'Pick a task', text: 'Look for one labelled "good first issue": they are well scoped and clearly explained.' },
{ title: 'Say so on the issue', text: 'Leave a comment so nobody works on the same thing. If you have questions, ask them there.' },
{ title: 'Make your change', text: 'Branch off the main branch, make the change and check that the tests pass.' },
{ title: 'Open a pull request', text: 'Explain what you changed and why. Link the issue so it closes on its own.' },
{ title: 'Review', text: 'Someone from the collective will go through your code with you. It is a conversation, not an exam.' },
],
otherWays: 'Other ways to help',
otherWaysLead: 'We also need design, documentation, translations and people to try what we build. Write to us and we will talk it over.',
},
blog: {
eyebrow: 'Blog',
title: 'News',
lead: 'What we are working on, what we have learned and what comes next.',
empty: 'We have not published anything yet. Come back soon.',
readMore: 'Read',
published: 'Published on',
feed: 'Subscribe via RSS',
seeAll: 'See all the news',
by: 'By',
tags: 'Topics',
allTags: 'Everything',
tagged: 'Posts about “{tag}”',
noneTagged: 'There are no posts with this tag.',
},
service: {
eyebrow: 'Service',
projects: 'Related work',
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.',
ringLead: {
adopt: 'We use it on real projects and recommend it without reservation.',
trial: 'We are trying it out on projects where the risk is acceptable.',
assess: 'It interests us and we are looking into it, without using it seriously yet.',
hold: 'For now we would rather not start anything new with it.',
},
categories: {
language: 'Language',
framework: 'Framework',
datastore: 'Data',
infrastructure: 'Infrastructure',
tool: 'Tool',
practice: 'Practice',
},
website: 'Official website',
projects: 'projects',
empty: 'We have not placed any technology on the radar yet.',
seeAll: 'See the full radar',
},
call: {
eyebrow: 'Open call',
seniority: { any: 'Any level', junior: 'Junior', mid: 'Mid-level', senior: 'Senior' },
whatYouGet: 'What you get',
commitment: 'Commitment',
totalHours: 'hours in total',
project: 'Project',
apply: 'Apply',
applyLead: 'Tell us who you are and what you would like to do. A person will answer, not a bot.',
unavailable: 'This call is no longer open',
unavailableLead: 'If you would like to help anyway, write to us from the general form.',
},
maintenance: {
title: 'We are under maintenance',
@@ -361,6 +723,7 @@ const ca: Copy = {
email: 'Correu',
organisation: 'Organització',
hours: 'Hores per setmana',
hoursHint: 'Per exemple, 4',
message: 'Missatge',
messageHint: 'Mínim 20 caràcters.',
consent: 'Accepto que deseu les meves dades per respondre’m.',
@@ -372,9 +735,135 @@ const ca: Copy = {
closed: 'El formulari està tancat temporalment.',
},
footer: {
built: 'Fet amb Angular, Analog i Nx.',
api: 'Contingut servit per l’API pública de Pireon.',
rights: 'Tots els drets reservats.',
pages: 'Més sobre Pireon',
},
page: {
skip: 'Salta al contingut',
related: 'Continua llegint',
ctaTitle: 'En parlem?',
ctaLead: "Si alguna cosa d'aquestes encaixa amb el que busques, escriu-nos i et respondrem.",
},
project: {
decisions: 'Decisions tècniques',
postmortem: 'Què vam aprendre',
gallery: 'Galeria',
details: 'El projecte',
status: 'Estat',
client: 'Per a',
period: 'Dates',
lead: 'Responsable',
team: 'Equip',
repositories: 'Codi',
forks: 'forks',
dormant: 'Sense activitat recent',
archived: 'Arxivat',
openCalls: 'Busquem ajuda',
technologies: 'Tecnologies',
roadmap: 'Full de ruta',
roadmapLead: 'El que ve, segons el nostre tauler.',
releases: 'Versions',
released: 'Publicada',
planned: 'Prevista',
sprint: 'Ara mateix',
goodFirst: 'Per començar a col·laborar',
goodFirstLead: 'Tasques pensades per a qui arriba de nou.',
board: 'Veure el tauler',
done: 'Fet',
proposals: 'Idees i errors',
proposalsLead: 'El que més es demana. Per votar, entra al tauler.',
propose: "Proposar una idea o avisar d'un error",
voteOne: '1 vot',
voteMany: '{n} vots',
},
member: {
projects: 'Projectes',
technologies: 'Tecnologies',
links: 'Enllaços',
since: 'Al col·lectiu des de',
},
contribute: {
eyebrow: 'Col·labora',
title: 'La teva primera contribució',
lead: "No cal ser experta ni tenir gaire temps. Tria alguna cosa petita, pregunta el que necessitis i t'acompanyem fins que el teu canvi estigui publicat.",
calls: 'Convocatòries obertes',
callsLead: 'Projectes que busquen mans ara mateix, amb la dedicació setmanal clara.',
issues: 'Tasques per començar',
issuesLead: 'Issues dels nostres repositoris pensades per a qui arriba per primera vegada.',
noIssues: "Ara mateix no hi ha tasques etiquetades per començar. Escriu-nos i te'n buscarem una.",
guide: 'Com començar',
steps: [
{ title: 'Tria una tasca', text: "Busca'n una etiquetada com a «good first issue»: són acotades i estan ben explicades." },
{ title: 'Digues-ho a la issue', text: 'Deixa un comentari perquè ningú treballi en el mateix. Si tens dubtes, pregunta-ho allà.' },
{ title: 'Fes el teu canvi', text: 'Crea una branca des de la principal, fes el canvi i comprova que els tests passen.' },
{ title: 'Obre una pull request', text: 'Explica què has canviat i per què. Enllaça la issue perquè es tanqui sola.' },
{ title: 'Revisió', text: 'Algú del col·lectiu revisarà el teu codi amb tu. És una conversa, no un examen.' },
],
otherWays: "Altres maneres d'ajudar",
otherWaysLead: 'També necessitem disseny, documentació, traduccions i gent que provi el que fem. Escriu-nos i en parlem.',
},
blog: {
eyebrow: 'Blog',
title: 'Novetats',
lead: 'El que estem fent, el que hem après i el que ve.',
empty: 'Encara no hem publicat res. Torna aviat.',
readMore: 'Llegir',
published: 'Publicat el',
feed: "Subscriu-t'hi per RSS",
seeAll: 'Veure totes les novetats',
by: 'Per',
tags: 'Temes',
allTags: 'Tot',
tagged: 'Entrades sobre «{tag}»',
noneTagged: 'No hi ha entrades amb aquesta etiqueta.',
},
service: {
eyebrow: 'Servei',
projects: 'Feina relacionada',
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.",
ringLead: {
adopt: 'El fem servir en projectes reals i el recomanem sense reserves.',
trial: "L'estem provant en projectes on el risc és assumible.",
assess: "Ens interessa i l'estem estudiant, sense fer-lo servir encara seriosament.",
hold: 'Ara per ara preferim no començar res de nou amb això.',
},
categories: {
language: 'Llenguatge',
framework: 'Framework',
datastore: 'Dades',
infrastructure: 'Infraestructura',
tool: 'Eina',
practice: 'Pràctica',
},
website: 'Web oficial',
projects: 'projectes',
empty: 'Encara no hem col·locat cap tecnologia al radar.',
seeAll: 'Veure el radar sencer',
},
call: {
eyebrow: 'Convocatòria',
seniority: { any: 'Qualsevol nivell', junior: 'Júnior', mid: 'Intermedi', senior: 'Sènior' },
whatYouGet: "Què t'emportes",
commitment: 'Dedicació',
totalHours: 'hores en total',
project: 'Projecte',
apply: "Apunta-t'hi",
applyLead: "Explica'ns qui ets i què t'agradaria fer. Et responen persones, no un bot.",
unavailable: 'Aquesta convocatòria ja no està oberta',
unavailableLead: "Si igualment t'interessa col·laborar, escriu-nos des del formulari general.",
},
maintenance: {
title: 'Estem de manteniment',

View 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>');
});
});

View 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);
}

View 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();
});
});

View 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;
}

View 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();
});
});

View 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 };
}

View File

@@ -0,0 +1,185 @@
/**
* What a page reached by its address renders — a content page, a project, a
* member — fetched on the server in one pass.
*
* Shared by the routes that differ only in how many segments the address has
* (`/sobre-nosotros`, `/proyectos/x`, `/en/projects/x`), whose `.server.ts`
* files exist because Analog needs one next to each `.page.ts`.
*/
import {
pireonApi,
DEFAULT_LOCALE,
type Locale,
type MaintenanceNotice,
type SitePayload,
} from './api';
import type {
PublicCall,
PublicIssue,
PublicMember,
PublicPage,
PublicProject,
PublicService,
PublicTechnology,
} from './api.types';
import { BLOG_CATEGORY, byPublication, isBlogPost } from './blog';
import { copyFor } from './copy';
import { listingPath, resolveRoute, type RouteMatch } from './routes';
import {
fetchShowcase,
hasContent,
type ProjectBoard,
} from './taskstream';
/** The record, tagged with its type so the template can switch on it. */
export type RoutedRecord =
| { type: 'page'; record: PublicPage }
| { type: 'project'; record: PublicProject; board: ProjectBoard | null }
| { type: 'member'; record: PublicMember }
| { type: 'call'; record: PublicCall }
| { type: 'service'; record: PublicService }
| { type: 'technology'; record: PublicTechnology }
| { type: 'radar'; record: PublicTechnology[] }
| { type: 'blog'; record: PublicPage[] }
| {
type: 'contribute';
record: { calls: PublicCall[]; issues: PublicIssue[] };
};
export interface RoutedPageData {
locale: Locale;
site: SitePayload | null;
maintenance: MaintenanceNotice | null;
/** Null when there is nothing at this address: the route answers 404. */
found: RoutedRecord | null;
/** Every published page, for the header and the footer. */
pages: PublicPage[];
}
export async function loadRoutedPage(
pathname: string,
): Promise<RoutedPageData> {
const route = resolveRoute(pathname);
const locale = route?.locale ?? DEFAULT_LOCALE;
const [site, found, pages] = await Promise.all([
pireonApi.site(locale),
route ? fetchRecord(route) : Promise.resolve(null),
pireonApi.pages(locale, { per_page: 100 }),
]);
return {
locale,
site: site.data,
maintenance: site.maintenance,
found,
pages: pages.data ?? [],
};
}
async function fetchRecord(route: RouteMatch): Promise<RoutedRecord | null> {
const { locale, slug } = route;
switch (route.type) {
case 'page': {
const { data } = await pireonApi.page(locale, slug);
return data ? { type: 'page', record: data } : null;
}
case 'project': {
const { data } = await pireonApi.project(locale, slug);
return data
? { type: 'project', record: data, board: await boardOf(data) }
: null;
}
case 'member': {
const { data } = await pireonApi.member(locale, slug);
return data ? { type: 'member', record: data } : null;
}
case 'call': {
const { data } = await pireonApi.call(locale, slug);
return data ? { type: 'call', record: data } : null;
}
case 'service': {
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],
per_page: 50,
});
// An empty blog is still the blog: the page says so rather than 404.
return error ? null : { type: 'blog', record: byPublication(data ?? []) };
}
case 'contribute': {
const [calls, issues] = await Promise.all([
pireonApi.calls(locale),
pireonApi.goodFirstIssues({ limit: 30 }),
]);
// Nothing open is still an answer: the page says how to help anyway.
return {
type: 'contribute',
record: { calls: calls.data ?? [], issues: issues.data ?? [] },
};
}
case 'radar': {
// An empty radar is still the radar: the page says so rather than 404.
const { data, error } = await pireonApi.radar(locale);
return error ? null : { type: 'radar', record: data ?? [] };
}
default:
// A type the site has no page for yet: an address the API hands out
// but nothing here renders, which is a 404 until it does.
return null;
}
}
/**
* Featured pages go in the header; the rest are one click away in the footer.
* Blog posts are neither: they have the blog, which joins the header as soon
* as there is a post in it — a blog reachable only from the footer is one
* nobody finds.
*/
export function pageNavLinks(
pages: PublicPage[],
locale: Locale,
): { href: string; label: string }[] {
const featured = pages
.filter(
(page) =>
page.is_featured &&
page.path &&
page.title &&
!isBlogPost(page, locale),
)
.map((page) => ({
href: page.path as string,
label: page.title as string,
}));
const hasPosts = pages.some((page) => isBlogPost(page, locale));
return hasPosts
? [
...featured,
{
href: listingPath('blog', locale),
label: copyFor(locale).blog.eyebrow,
},
]
: featured;
}
/** The project's TaskStream board, when it has one with something to show. */
async function boardOf(project: PublicProject): Promise<ProjectBoard | null> {
const board = project.taskstream;
if (!board?.showcase_url) return null;
const showcase = await fetchShowcase(board.showcase_url);
return showcase && hasContent(showcase)
? { url: board.url, webUrl: board.web_url, showcase }
: null;
}

View File

@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from './api';
import {
homeHref,
listingAlternates,
listingPath,
resolveRoute,
SECTIONS,
technologyAlternates,
technologyPath,
} from './routes';
describe('resolveRoute', () => {
it('reads a page at the root as the default language', () => {
expect(resolveRoute('/sobre-nosotros')).toEqual({
type: 'page',
locale: DEFAULT_LOCALE,
slug: 'sobre-nosotros',
});
});
it('reads a prefixed page in the language it names', () => {
expect(resolveRoute('/en/about-us')).toEqual({
type: 'page',
locale: 'en',
slug: 'about-us',
});
expect(resolveRoute('/ca/qui-som/')).toEqual({
type: 'page',
locale: 'ca',
slug: 'qui-som',
});
});
it('reads a record under its section, in each language', () => {
expect(resolveRoute('/proyectos/catalogo')).toEqual({
type: 'project',
locale: 'es',
slug: 'catalogo',
});
expect(resolveRoute('/en/projects/catalogo')).toEqual({
type: 'project',
locale: 'en',
slug: 'catalogo',
});
expect(resolveRoute('/ca/equip/ada')).toEqual({
type: 'member',
locale: 'ca',
slug: 'ada',
});
expect(resolveRoute('/ca/col-labora/frontend')).toEqual({
type: 'call',
locale: 'ca',
slug: 'frontend',
});
});
// A section only means something in its own language: `/projects/x` is not
// an address the API hands out for Spanish.
it('refuses a section spelled in another language', () => {
expect(resolveRoute('/projects/catalogo')).toBeNull();
expect(resolveRoute('/en/proyectos/catalogo')).toBeNull();
});
// The API never spells the default language with a prefix, so one record
// has one address rather than two.
it('refuses the default language as a prefix', () => {
expect(resolveRoute('/es/sobre-nosotros')).toBeNull();
expect(resolveRoute('/es/proyectos/catalogo')).toBeNull();
});
it('refuses what is not shaped like a record', () => {
expect(resolveRoute('/')).toBeNull();
expect(resolveRoute('/fr/a-propos')).toBeNull();
expect(resolveRoute('/proyectos/catalogo/extra')).toBeNull();
});
it('knows every section in every language', () => {
for (const sections of Object.values(SECTIONS)) {
expect(Object.keys(sections).sort()).toEqual(
[...SUPPORTED_LOCALES].sort(),
);
}
});
});
describe('listings', () => {
it('reads the radar in every language, before any page of that slug', () => {
expect(resolveRoute('/radar')).toEqual({
type: 'radar',
locale: 'es',
slug: '',
});
expect(resolveRoute('/ca/radar')).toEqual({
type: 'radar',
locale: 'ca',
slug: '',
});
});
it('builds the address resolveRoute reads back', () => {
for (const locale of SUPPORTED_LOCALES) {
expect(resolveRoute(listingPath('radar', locale))?.type).toBe('radar');
}
expect(listingAlternates('radar')).toEqual({
es: '/radar',
en: '/en/radar',
ca: '/ca/radar',
});
});
// The calls' section with nothing after it is the list of ways to help.
it('reads a call section on its own as the contribute listing', () => {
expect(resolveRoute('/colabora')).toEqual({
type: 'contribute',
locale: 'es',
slug: '',
});
expect(resolveRoute('/en/contribute')?.type).toBe('contribute');
expect(resolveRoute('/en/contribute/frontend')?.type).toBe('call');
});
});
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('/');
expect(homeHref('ca')).toBe('/?lang=ca');
});
});

View File

@@ -0,0 +1,137 @@
/**
* Which record an address on this site belongs to.
*
* The API decides every record's address — it sends `path` and `alternates`
* with each one — and this is the same rule read backwards. The default
* language has no prefix, the others their code; pages sit at the root, every
* other type under a section named in the visitor's language:
*
* /sobre-nosotros /en/about-us /ca/qui-som
* /proyectos/catalogo /en/projects/catalogo /ca/projectes/catalogo
*
* Only the shape is known here. Whether the record exists is the API's answer.
*/
import { DEFAULT_LOCALE, SUPPORTED_LOCALES, type Locale } from './api';
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';
/**
* The section each type lives under, per language. The API's own
* `config/site.php` (`paths`) is the source; a change there has to be made here
* too, or the API will hand out addresses this site answers 404 on.
*/
export const SECTIONS: Record<
Exclude<RecordType, 'page'>,
Record<Locale, string>
> = {
project: { es: 'proyectos', en: 'projects', ca: 'projectes' },
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' },
};
/**
* Where each listing lives, per language. A panel page with the same slug
* would be shadowed by it, so these are few and deliberately named.
*/
export const LISTINGS: Record<ListingType, Record<Locale, string>> = {
radar: { es: 'radar', en: 'radar', ca: 'radar' },
// The calls' own section, so /colabora lists what /colabora/{slug} are.
contribute: { es: 'colabora', en: 'contribute', ca: 'col-labora' },
blog: { es: 'blog', en: 'blog', ca: 'blog' },
};
export interface RouteMatch {
type: RecordType | ListingType;
locale: Locale;
/** Empty for a listing. */
slug: string;
}
/**
* A prefix naming the default language (`/es/…`) is not a shape the API ever
* produces, so it is not one this answers on either — one record, one address.
*/
export function resolveRoute(pathname: string): RouteMatch | null {
const segments = pathname.replace(/\/+$/, '').split('/').filter(Boolean);
const [first] = segments;
const prefixed =
first !== DEFAULT_LOCALE && SUPPORTED_LOCALES.includes(first as Locale);
const locale = prefixed ? (first as Locale) : DEFAULT_LOCALE;
const rest = prefixed ? segments.slice(1) : segments;
if (rest.length === 1) {
const listing = (Object.keys(LISTINGS) as ListingType[]).find(
(candidate) => LISTINGS[candidate][locale] === rest[0],
);
return listing
? { type: listing, locale, slug: '' }
: { type: 'page', locale, slug: rest[0] };
}
if (rest.length === 2) {
const type = (Object.keys(SECTIONS) as (keyof typeof SECTIONS)[]).find(
(candidate) => SECTIONS[candidate][locale] === rest[0],
);
return type ? { type, locale, slug: rest[1] } : null;
}
return null;
}
/** A listing's address in one language, as `resolveRoute` reads it back. */
export function listingPath(listing: ListingType, locale: Locale): string {
return `${locale === DEFAULT_LOCALE ? '' : `/${locale}`}/${LISTINGS[listing][locale]}`;
}
/** The same listing in every language, for `hreflang`. */
export function listingAlternates(
listing: ListingType,
): Record<string, string> {
return Object.fromEntries(
SUPPORTED_LOCALES.map((locale) => [locale, listingPath(listing, locale)]),
);
}
/**
* 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<string, string> {
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}`;
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import type { PublicPage } from './api.types';
import { byPublication, isBlogPost } from './blog';
import { buildFeed } from './rss';
const post = (id: number, title: string, published_at: string, slug = 'blog') =>
({
id,
title,
path: `/${title.toLowerCase().replace(/\s/g, '-')}`,
excerpt: 'Resumen con <etiquetas> & símbolos',
published_at,
category: { id: 9, slug, name: 'Blog' },
}) as unknown as PublicPage;
describe('blog', () => {
it("knows a post by its category, in the visitor's language", () => {
expect(isBlogPost(post(1, 'Hola', '2026-01-01'), 'es')).toBe(true);
expect(isBlogPost(post(2, 'Sobre', '2026-01-01', 'colectivo'), 'es')).toBe(
false,
);
});
it('orders posts newest first', () => {
const ordered = byPublication([
post(1, 'Viejo', '2026-01-01'),
post(2, 'Nuevo', '2026-09-01'),
]);
expect(ordered.map((p) => p.title)).toEqual(['Nuevo', 'Viejo']);
});
});
describe('buildFeed', () => {
const xml = buildFeed(
'https://pireon.dev',
'es',
{ title: 'Pireon — Blog', description: 'Novedades', link: '/blog' },
[post(1, 'Primer post', '2026-09-01T10:00:00Z')],
);
it('links every item absolutely, with its date', () => {
expect(xml).toContain('<link>https://pireon.dev/primer-post</link>');
expect(xml).toContain('<pubDate>Tue, 01 Sep 2026 10:00:00 GMT</pubDate>');
});
it('escapes what it carries and parses as XML', () => {
expect(xml).toContain('&lt;etiquetas&gt; &amp; símbolos');
expect(
new DOMParser()
.parseFromString(xml, 'application/xml')
.getElementsByTagName('parsererror'),
).toHaveLength(0);
});
});

View File

@@ -0,0 +1,56 @@
/**
* The blog as RSS 2.0, for feed readers and for anything that republishes it.
*/
import type { Locale } from './api';
import type { PublicPage } from './api.types';
export function buildFeed(
origin: string,
locale: Locale,
channel: { title: string; description: string; link: string },
posts: PublicPage[],
): string {
const items = posts.map((post) => {
const link = `${origin}${post.path ?? ''}`;
return [
' <item>',
` <title>${escape(post.title ?? '')}</title>`,
` <link>${escape(link)}</link>`,
` <guid isPermaLink="true">${escape(link)}</guid>`,
...(post.published_at
? [
` <pubDate>${new Date(post.published_at).toUTCString()}</pubDate>`,
]
: []),
...(post.excerpt
? [` <description>${escape(post.excerpt)}</description>`]
: []),
' </item>',
].join('\n');
});
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
' <channel>',
` <title>${escape(channel.title)}</title>`,
` <link>${escape(origin + channel.link)}</link>`,
` <description>${escape(channel.description)}</description>`,
` <language>${locale}</language>`,
` <atom:link href="${escape(`${origin}/blog/rss.xml?lang=${locale}`)}" rel="self" type="application/rss+xml"/>`,
...items,
' </channel>',
'</rss>',
'',
].join('\n');
}
function escape(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

View File

@@ -0,0 +1,73 @@
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',
},
},
],
}, [
{
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', () => {
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>');
});
// 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('<loc>https://pireon.dev/tecnologias/angular</loc>');
expect(xml).toContain(
'<loc>https://pireon.dev/en/technologies/angular</loc>',
);
expect(xml).toContain(
'<loc>https://pireon.dev/ca/tecnologies/angular</loc>',
);
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');
expect(document.getElementsByTagName('parsererror')).toHaveLength(0);
});
});

View File

@@ -0,0 +1,94 @@
/**
* `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 type { PublicTechnology } from './api.types';
import {
homeHref,
listingAlternates,
LISTINGS,
technologyAlternates,
type ListingType,
} from './routes';
interface SitemapEntry {
alternates: Record<string, string>;
updated_at?: string;
}
export function buildSitemap(
origin: string,
slugs: Record<string, SlugEntry[]>,
technologies: PublicTechnology[] = [],
): 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(),
// Technologies are absent from `/slugs` — the API gives them no address,
// so this site composes theirs. No `<lastmod>`: the payload has no
// `updated_at` to report.
...technologies.map((technology) => ({
alternates: technologyAlternates(technology.slug),
})),
];
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { checkSubmission } from './submission';
const valid = {
name: 'Ada',
email: 'ada@example.org',
message: 'Me gustaría echar una mano con el frontend.',
consent: true,
};
describe('checkSubmission', () => {
it('passes a complete contact through, trimmed', () => {
const { payload } = checkSubmission({
...valid,
name: ' Ada ',
hours_per_week: '4',
});
expect(payload).toMatchObject({
name: 'Ada',
kind: 'contribute',
hours_per_week: 4,
});
});
it('names every missing field at once', () => {
const { errors } = checkSubmission({ message: 'corto' });
expect(Object.keys(errors ?? {}).sort()).toEqual([
'consent',
'email',
'message',
'name',
]);
});
it('asks an organisation asking for help to name itself', () => {
expect(
checkSubmission({ ...valid, kind: 'request' }).errors,
).toHaveProperty('organisation');
expect(
checkSubmission({ ...valid, kind: 'request', organisation: 'Asociación' })
.payload,
).toMatchObject({
kind: 'request',
});
});
// The API refuses a `kind` on an application to a call: it is always an
// offer of time.
it('sends no kind with an application to a call, whatever the body says', () => {
const { payload } = checkSubmission(
{ ...valid, kind: 'request' },
{ forCall: true },
);
expect(payload).toBeDefined();
expect(payload).not.toHaveProperty('kind');
});
it('keeps the honeypot, so the API can file a bot as spam', () => {
expect(
checkSubmission({ ...valid, website: 'http://spam' }).payload,
).toHaveProperty('website');
});
});

View File

@@ -0,0 +1,88 @@
/**
* What the site's two forms send — the general contact and an application to
* a call — checked before it costs a round trip.
*
* Pure, so the two server routes share it and it can be tested without a
* request: the API would answer 422 with the same verdict, and its throttle
* counts the attempt either way.
*/
import type { SubmissionPayload } from './api';
export interface SubmissionBody {
name?: string;
email?: string;
message?: string;
organisation?: string | null;
hours_per_week?: number | string | null;
kind?: 'contribute' | 'request';
consent?: boolean;
links?: Record<string, string>;
/** Honeypot. Never rendered visibly; a bot that fills it is filed as spam. */
website?: string | null;
}
export type SubmissionCheck =
| { errors: Record<string, string[]>; payload?: undefined }
| { errors?: undefined; payload: SubmissionPayload };
/**
* @param forCall An application to a call is always an offer of time: the API
* refuses a `kind` on it, so none is sent, and no organisation is asked for.
*/
export function checkSubmission(
body: SubmissionBody,
{ forCall = false } = {},
): SubmissionCheck {
const kind = !forCall && body.kind === 'request' ? 'request' : 'contribute';
const name = body.name?.trim() ?? '';
const email = body.email?.trim() ?? '';
const message = body.message?.trim() ?? '';
const organisation = body.organisation?.trim() ?? '';
const errors: Record<string, string[]> = {};
if (name.length < 2) {
errors['name'] = ['required'];
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
errors['email'] = ['required'];
}
// The API rejects anything shorter, and rightly: a one-word submission is
// never anything but noise.
if (message.length < 20) {
errors['message'] = ['min'];
}
if (kind === 'request' && organisation.length === 0) {
errors['organisation'] = ['required'];
}
if (body.consent !== true) {
errors['consent'] = ['required'];
}
if (Object.keys(errors).length > 0) {
return { errors };
}
const hours = Number(body.hours_per_week);
return {
payload: {
name,
email,
message,
consent: true,
...(forCall ? {} : { kind }),
organisation: organisation || null,
hours_per_week: Number.isFinite(hours) && hours > 0 ? hours : null,
...(body.links && Object.keys(body.links).length > 0
? { links: body.links }
: {}),
...(body.website ? { website: body.website } : {}),
},
};
}

View File

@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { boardLink, fetchShowcase, hasContent } from './taskstream';
const showcase = {
project: { id: 'p', key: 'WEB', name: 'Web', description: null },
activeSprint: null,
roadmap: [],
releases: [],
goodFirst: [
{ key: 'WEB-9', title: 'Typo', type: 'TASK', path: '/p/WEB/issues/WEB-9' },
],
};
describe('TaskStream showcase', () => {
afterEach(() => vi.unstubAllGlobals());
it('reads a showcase, and goes without it when TaskStream fails', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(JSON.stringify(showcase))),
);
expect(await fetchShowcase('https://api.ts/showcase')).toEqual(showcase);
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('nope', { status: 404 })),
);
expect(await fetchShowcase('https://api.ts/showcase')).toBeNull();
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('down');
}),
);
expect(await fetchShowcase('https://api.ts/showcase')).toBeNull();
});
it('builds links into the board and knows an empty one', () => {
expect(
boardLink('https://taskstream.pireon.dev/', '/p/WEB/issues/WEB-9'),
).toBe('https://taskstream.pireon.dev/p/WEB/issues/WEB-9');
expect(hasContent(showcase)).toBe(true);
expect(hasContent({ ...showcase, goodFirst: [] })).toBe(false);
});
it('counts a board that takes proposals as something to show', () => {
const empty = { ...showcase, goodFirst: [] };
expect(
hasContent({
...empty,
proposals: { open: true, path: '/p/WEB/proponer', top: [] },
}),
).toBe(true);
expect(
hasContent({ ...empty, proposals: { open: false, path: null, top: [] } }),
).toBe(false);
});
});

View File

@@ -0,0 +1,104 @@
/**
* A project's board in TaskStream, the collective's issue tracker: its public
* showcase (roadmap, releases, the sprint under way, tickets for newcomers
* and the proposals people send and vote for). Fetched on the server; a slow or absent TaskStream only means
* the project page goes without those sections.
*/
export interface TaskStreamShowcase {
project: {
id: string;
key: string;
name: string;
description: string | null;
};
activeSprint: {
name: string;
goal: string | null;
startDate: string;
endDate: string;
percent: number;
} | null;
roadmap: {
key: string;
title: string;
startDate: string | null;
dueDate: string | null;
percent: number;
done: boolean;
/** How many want it; older TaskStreams leave it out. */
votes?: number;
path: string;
}[];
releases: {
name: string;
status: 'PLANNED' | 'RELEASED' | 'ARCHIVED';
targetDate: string | null;
releasedAt: string | null;
percent: number;
notes: string | null;
}[];
goodFirst: { key: string; title: string; type: string; path: string }[];
/** Bug reports and ideas from anyone; older TaskStreams leave it out. */
proposals?: {
/** Whether it takes them now. */
open: boolean;
/** The form in TaskStream's web app, while it takes them. */
path: string | null;
/** Open ones, most voted first. */
top: {
key: string;
title: string;
type: string;
path: string;
votes: number;
}[];
};
}
/** What the page renders: the showcase plus where its links go. */
export interface ProjectBoard {
url: string;
webUrl: string;
showcase: TaskStreamShowcase;
}
export async function fetchShowcase(
showcaseUrl: string,
timeout = 4000,
): Promise<TaskStreamShowcase | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(showcaseUrl, {
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (!response.ok) return null;
const body = (await response.json()) as Partial<TaskStreamShowcase>;
return Array.isArray(body.roadmap) && Array.isArray(body.goodFirst)
? (body as TaskStreamShowcase)
: null;
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
/** Something worth showing: a board with nothing on it says nothing. */
export function hasContent(showcase: TaskStreamShowcase): boolean {
return (
showcase.roadmap.length > 0 ||
showcase.releases.length > 0 ||
showcase.goodFirst.length > 0 ||
showcase.activeSprint !== null ||
!!showcase.proposals?.open ||
(showcase.proposals?.top.length ?? 0) > 0
);
}
/** A showcase path as an address in TaskStream's web app. */
export function boardLink(webUrl: string, path: string): string {
return `${webUrl.replace(/\/$/, '')}${path}`;
}

View File

@@ -9,7 +9,7 @@ import {
import { toSignal } from '@angular/core/rxjs-interop';
import { Meta, Title } from '@angular/platform-browser';
import { injectLoad, type RouteMeta } from '@analogjs/router';
import { injectResponse } from '@analogjs/router/tokens';
import { injectRequest, injectResponse } from '@analogjs/router/tokens';
import {
PiCardCenterContentDirective,
@@ -17,7 +17,13 @@ import {
PiChipComponent,
} from '@pi/ui';
import { byPublication, isBlogPost } from '../core/blog';
import { pageNavLinks } from '../core/routed-page';
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';
@@ -25,6 +31,7 @@ import { Hero } from '../ui/hero';
import { IssueRow } from '../ui/issue-row';
import { MaintenanceScreen } from '../ui/maintenance-screen';
import { MemberCard } from '../ui/member-card';
import { PostCard } from '../ui/post-card';
import { ProjectCard } from '../ui/project-card';
import { Section } from '../ui/section';
import { ServiceCard } from '../ui/service-card';
@@ -61,6 +68,7 @@ export const routeMeta: RouteMeta = {
IssueRow,
MaintenanceScreen,
MemberCard,
PostCard,
ProjectCard,
Section,
ServiceCard,
@@ -68,6 +76,7 @@ export const routeMeta: RouteMeta = {
SiteHeader,
TalkCard,
TestimonialCarousel,
TechnologyChip,
],
styleUrl: './home.page.scss',
template: `
@@ -119,7 +128,11 @@ export const routeMeta: RouteMeta = {
>
<div class="landing-grid">
@for (project of data().projects; track project.id) {
<app-project-card [project]="project" [copy]="copy()" />
<app-project-card
[project]="project"
[copy]="copy()"
[locale]="data().locale"
/>
}
</div>
</app-section>
@@ -172,11 +185,10 @@ export const routeMeta: RouteMeta = {
<ul class="landing-card__chips">
@for (technology of group.technologies; track technology.id) {
<li>
<pi-chip
size="small"
appearance="outline"
[label]="technology.name"
/>
<app-technology-chip
[technology]="technology"
[locale]="data().locale"
/>
</li>
}
</ul>
@@ -184,6 +196,9 @@ export const routeMeta: RouteMeta = {
</pi-card>
}
</div>
<p class="landing-section__more">
<a [href]="radarHref()">{{ copy().radar.seeAll }} →</a>
</p>
</app-section>
}
@@ -235,6 +250,9 @@ export const routeMeta: RouteMeta = {
</ul>
</div>
}
<p class="landing-section__more">
<a [href]="contributeHref()">{{ copy().contribute.title }} →</a>
</p>
</app-section>
}
@@ -252,6 +270,25 @@ export const routeMeta: RouteMeta = {
</app-section>
}
<!-- The latest posts, so the blog is found from the landing rather than
only from a link in the footer. -->
@if (latestPosts().length > 0) {
<app-section
anchor="novedades"
[title]="copy().blog.title"
[lead]="copy().blog.lead"
>
<div class="landing-grid">
@for (post of latestPosts(); track post.id) {
<app-post-card [post]="post" [copy]="copy()" [locale]="data().locale" />
}
</div>
<p class="landing-section__more">
<a [href]="blogHref()">{{ copy().blog.seeAll }} →</a>
</p>
</app-section>
}
<app-section
anchor="contacto"
[title]="copy().sections.contact.title"
@@ -267,7 +304,7 @@ export const routeMeta: RouteMeta = {
</app-section>
</main>
<app-site-footer [copy]="copy()" />
<app-site-footer [copy]="copy()" [pages]="data().pages" [locale]="data().locale" />
}
`,
})
@@ -276,12 +313,28 @@ export default class HomePage {
private readonly title = inject(Title);
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>(), {
requireSync: true,
});
protected readonly copy = computed(() => copyFor(this.data().locale));
protected readonly radarHref = computed(() => listingPath('radar', this.data().locale));
protected readonly blogHref = computed(() => listingPath('blog', this.data().locale));
/** The three most recent posts; the rest are on the blog. */
protected readonly latestPosts = computed(() =>
byPublication(this.data().pages.filter((page) => isBlogPost(page, this.data().locale))).slice(0, 3),
);
protected readonly contributeHref = computed(() =>
listingPath('contribute', this.data().locale),
);
/** The switcher needs somewhere to go even if `/site` was unreachable. */
protected readonly locales = computed(
() =>
@@ -318,7 +371,14 @@ export default class HomePage {
},
];
return candidates.filter((link) => link.visible);
// After the sections: the featured pages the panel publishes, such as
// "Sobre nosotros", which live on their own addresses.
return [
...candidates
.filter((link) => link.visible)
.map(({ anchor, label }) => ({ anchor, label })),
...pageNavLinks(data.pages, data.locale),
];
});
/**
@@ -400,6 +460,18 @@ export default class HomePage {
: `Pireon — ${copy.hero.title} ${copy.hero.highlight}`
);
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,
});
});
}
}

View File

@@ -21,6 +21,7 @@ import type {
PublicClient,
PublicIssue,
PublicMember,
PublicPage,
PublicProject,
PublicService,
PublicTalk,
@@ -41,6 +42,8 @@ export interface LandingData {
calls: PublicCall[];
issues: PublicIssue[];
talks: PublicTalk[];
/** The pages the panel publishes: featured ones in the nav, all of them in the footer. */
pages: PublicPage[];
}
export const load = async ({ req }: PageServerLoad): Promise<LandingData> => {
@@ -60,6 +63,7 @@ export const load = async ({ req }: PageServerLoad): Promise<LandingData> => {
calls,
issues,
talks,
pages,
] = await Promise.all([
pireonApi.site(locale),
pireonApi.services(locale),
@@ -71,6 +75,7 @@ export const load = async ({ req }: PageServerLoad): Promise<LandingData> => {
pireonApi.calls(locale),
pireonApi.goodFirstIssues({ limit: 6 }),
pireonApi.talks(locale, { limit: 6 }),
pireonApi.pages(locale, { per_page: 100 }),
]);
return {
@@ -88,5 +93,6 @@ export const load = async ({ req }: PageServerLoad): Promise<LandingData> => {
calls: calls.data ?? [],
issues: issues.data ?? [],
talks: talks.data ?? [],
pages: pages.data ?? [],
};
};

View File

@@ -0,0 +1,18 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { injectLoad } from '@analogjs/router';
import { RoutedPage } from '../ui/routed-page';
import { load } from './[...path].server';
@Component({
selector: 'app-route-path',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RoutedPage],
template: `<app-routed-page [data]="data()" />`,
})
export default class PathRoute {
protected readonly data = toSignal(injectLoad<typeof load>(), {
requireSync: true,
});
}

View File

@@ -0,0 +1,18 @@
/**
* Every address but the landing's: a page (`/sobre-nosotros`), a record
* (`/proyectos/x`, `/en/team/x`), or nothing at all.
*
* One catch-all rather than a route per shape. Analog registers each loader as
* an internal route, and Nitro's router cannot hold two parameters of
* different names at the same depth — `/:prefix/:slug` beside
* `/:prefix/:section/:slug` left the first unreachable. `resolveRoute` sorts
* the shapes out instead, and an address it does not recognise loads as a 404.
*/
import type { PageServerLoad } from '@analogjs/router';
import { loadRoutedPage, type RoutedPageData } from '../core/routed-page';
export const load = async ({
params,
}: PageServerLoad): Promise<RoutedPageData> =>
loadRoutedPage(`/${params?.['path'] ?? ''}`);

View File

@@ -40,6 +40,11 @@
margin-top: 1.25rem;
padding: 0;
list-style: none;
// `@pi/ui`'s reset spaces every `li + li`; this list is spaced by `gap`.
> li {
margin: 0;
}
}
.landing-subsection {
@@ -73,3 +78,18 @@
border-radius: 0.5rem;
}
}
// "See the full radar" under the section's summary of it.
.landing-section__more {
margin: 2rem 0 0;
a {
color: var(--pi-color-primary);
font-weight: 600;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -0,0 +1,365 @@
// The layout every page reached by its address shares — a content page, a
// project, a member — so moving between them reads as one site.
//
// A partial each body `@use`s rather than a global sheet: component styles are
// scoped, and each body gets its own scoped copy of these rules.
// The top of the page is the landing's hero, minus the stats and buttons: the
// same left edge as the logo, the same heading weight.
.content-hero {
padding-block: 5rem 2.5rem;
@media (min-width: 768px) {
padding-block: 7rem 3.5rem;
}
}
.content-hero__title {
margin: 0;
max-width: 22ch;
font-size: clamp(2.25rem, 5.5vw, 3.5rem);
line-height: 1.08;
letter-spacing: -0.02em;
}
.content-hero__lead {
max-width: 46ch;
margin: 1.5rem 0 0;
}
// Reading column and a side column, on the landing's own grid width. The
// column keeps prose to a readable measure; the side keeps the page from
// ending in a dead end.
.content-page__layout {
display: grid;
// One column below the desktop layout, and one that may shrink: an
// implicit `auto` track grows to its widest line, and a code block in a
// post pushed the whole text past the edge of a phone.
grid-template-columns: minmax(0, 1fr);
gap: 3rem;
padding-bottom: 5rem;
@media (min-width: 1024px) {
grid-template-columns: minmax(0, 46rem) minmax(16rem, 20rem);
justify-content: space-between;
gap: 4rem;
padding-bottom: 7rem;
}
}
.content-page__media {
margin: 0 0 2.5rem;
overflow: hidden;
border-radius: var(--pi-theme-card-radius, 1.5rem);
}
// The `<img>` belongs to `app-api-image`, so a scoped rule cannot reach it.
.content-page__media ::ng-deep img {
display: block;
width: 100%;
height: auto;
}
.content-page__body {
font-size: 1.0625rem;
// The body is HTML from the panel's editor, not this component's template,
// so it carries none of the scoping attributes: only `::ng-deep` reaches it.
// Confined to this container, it styles nothing else on the page.
::ng-deep {
// Bold, like every heading on the landing — a light heading here was the
// one thing that made the page read as another site.
h2 {
margin: 3rem 0 1rem;
font-size: clamp(1.5rem, 3vw, 1.875rem);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.015em;
}
h3 {
margin: 2rem 0 0.75rem;
font-size: 1.25rem;
font-weight: 700;
}
// One rhythm for both: left alone, the library's sheet sets paragraphs
// tighter than list items and the body reads in two different spacings.
p,
li {
line-height: 1.7;
}
p {
margin: 0 0 1rem;
}
// The bullet outside the text, where a list's bullet goes. `@pi/ui`'s
// reset puts it inside, and the editor wraps each item's text in a `<p>`:
// together the bullet sat alone on one line and the text, a block, fell to
// the next — with a paragraph's margins on top.
ul,
ol {
margin: 0 0 1.5rem;
padding-left: 1.5rem;
}
li {
margin: 0 0 0.5rem;
padding-left: 0.25rem;
list-style-position: outside;
&::marker {
color: var(--pi-color-primary);
}
> p {
margin: 0;
}
}
strong {
font-weight: 700;
}
a {
color: var(--pi-color-primary);
}
code {
padding: 0.1em 0.35em;
border-radius: 0.375rem;
background: color-mix(in srgb, var(--pi-color-primary) 10%, transparent);
font-size: 0.9em;
}
pre {
margin: 0 0 1.5rem;
padding: 1rem 1.25rem;
overflow-x: auto;
border-radius: var(--pi-border-radius-medium, 0.75rem);
background: color-mix(in srgb, var(--pi-color-primary) 7%, transparent);
line-height: 1.55;
code {
padding: 0;
background: none;
}
}
// `@pi/ui` already frames a quote with its own quotation marks; only the
// spacing is this page's business.
blockquote {
margin: 0 0 1.5rem;
p:last-child {
margin-bottom: 0;
}
}
hr {
margin: 2.5rem 0;
border: 0;
border-top: 1px solid color-mix(in srgb, currentColor 15%, transparent);
}
// Images in the text — attached in the panel's editor, or by URL through
// the API. Never wider than the column, and set apart from the prose.
img {
display: block;
max-width: 100%;
height: auto;
margin: 2rem 0;
border-radius: var(--pi-border-radius-medium, 0.75rem);
}
figure {
margin: 2rem 0;
img {
margin: 0;
}
}
figcaption {
margin-top: 0.5rem;
font-size: 0.875rem;
color: color-mix(in srgb, currentColor 68%, transparent);
}
> :first-child {
margin-top: 0;
}
}
}
.content-page__aside {
display: flex;
flex-direction: column;
gap: 2rem;
// Stays in view beside a long page, under the sticky header.
@media (min-width: 1024px) {
position: sticky;
top: 6rem;
align-self: start;
}
}
.content-page__related {
display: grid;
gap: 1rem;
margin: 0;
padding: 0;
list-style: none;
}
// `landing-card` is the landing's card surface and hover, drawn on a link
// rather than on `pi-card`, which brings no padding or radius of its own here.
.content-page__card,
.content-page__cta {
display: block;
padding: 1.25rem 1.5rem;
border-radius: var(--pi-theme-card-radius, 1.5rem);
box-shadow: var(--pi-theme-card-shadow, none);
}
.content-page__card {
color: inherit;
text-decoration: none;
}
.content-page__card-title {
display: block;
font-size: 1.0625rem;
font-weight: 700;
}
.content-page__card-text {
display: -webkit-box;
margin-top: 0.375rem;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: 0.875rem;
line-height: 1.5;
// Relative to the card's own text colour, so it holds up in dark mode too,
// where the grey token is nearly the background.
color: color-mix(in srgb, currentColor 68%, transparent);
}
.content-page__cta {
padding: 1.5rem;
// The call to action is not a link as a whole, so it does not lift.
&:hover {
transform: none;
}
p {
margin: 0.5rem 0 1.25rem;
line-height: 1.6;
}
}
.content-page__cta-title {
margin: 0;
font-size: 1.375rem;
font-weight: 700;
}
// Hero actions — a demo, a repository — and the chips under them.
.content-hero__actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 2rem;
a {
text-decoration: none;
}
}
.content-hero__chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin: 1.5rem 0 0;
padding: 0;
list-style: none;
}
// A section inside the reading column, under its own bold heading.
.content-page__section {
margin-top: 3.5rem;
> h2 {
margin: 0 0 1.25rem;
font-size: clamp(1.5rem, 3vw, 1.875rem);
font-weight: 700;
line-height: 1.2;
letter-spacing: -0.015em;
}
}
.content-page__main > :first-child {
margin-top: 0;
}
// A card in the side column that holds facts rather than a link.
.content-page__panel {
display: block;
padding: 1.25rem 1.5rem;
border-radius: var(--pi-theme-card-radius, 1.5rem);
box-shadow: var(--pi-theme-card-shadow, none);
&:hover {
transform: none;
}
h2 {
margin: 0 0 1rem;
font-size: 1.0625rem;
font-weight: 700;
}
}
// Label and value, one pair a row.
.content-page__facts {
display: grid;
gap: 0.875rem;
margin: 0;
dt {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: color-mix(in srgb, currentColor 60%, transparent);
}
dd {
margin: 0.125rem 0 0;
}
a {
color: var(--pi-color-primary);
}
}
.content-page__muted {
color: color-mix(in srgb, currentColor 68%, transparent);
}
// Lists this layout spaces with `gap`. `@pi/ui`'s reset adds a top margin to
// every `li + li`, which suits a list of prose and staggers everything else.
.content-hero__chips,
.content-page__related {
> li {
margin: 0;
}
}
.content-hero__chips {
align-items: center;
}

View File

@@ -0,0 +1,53 @@
// The two soft lights behind the top of a page, shared by the landing's hero
// and the content pages so moving from one to the other does not feel like
// changing sites.
//
// Lights rather than an image: nothing to download, and nothing that dates the
// page when the brand shifts. `isolate` keeps the negative z-index inside the
// element instead of behind the page background.
//
// `$overflow: clip` for a tall element with sticky children: `hidden` makes the
// element a scroll container, and `position: sticky` inside one stops
// following the page.
@mixin glow($overflow: hidden) {
position: relative;
isolation: isolate;
overflow: $overflow;
&::before,
&::after {
content: '';
position: absolute;
z-index: -1;
border-radius: 50%;
pointer-events: none;
filter: blur(80px);
}
&::before {
top: -14rem;
left: 50%;
width: 64rem;
height: 38rem;
transform: translateX(-58%);
opacity: 0.5;
background: radial-gradient(
closest-side,
var(--pi-color-primary),
transparent
);
}
&::after {
top: 4rem;
right: -8rem;
width: 26rem;
height: 26rem;
opacity: 0.35;
background: radial-gradient(
closest-side,
var(--pi-color-accent),
transparent
);
}
}

View File

@@ -0,0 +1,71 @@
@use './detail';
:host {
display: block;
}
.blog-feed {
margin: 1.25rem 0 0;
a {
color: var(--pi-color-primary);
font-weight: 600;
text-decoration: none;
}
}
.blog-body {
padding-bottom: 6rem;
}
.blog-list {
display: grid;
gap: 1.5rem;
margin: 0;
padding: 0;
list-style: none;
// `@pi/ui`'s reset spaces every `li + li`; this list is spaced by `gap`.
> li {
margin: 0;
}
}
.blog-tagged {
margin: 1rem 0 0;
font-weight: 600;
}
.blog-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 2rem;
}
.blog-tag {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
border: 1px solid color-mix(in oklab, currentColor 18%, transparent);
border-radius: 999px;
color: inherit;
font-size: 0.875rem;
text-decoration: none;
&:hover {
border-color: var(--pi-color-primary);
}
&[aria-current='page'] {
border-color: var(--pi-color-primary);
background: var(--pi-color-primary);
color: var(--pi-color-primary-contrast, #fff);
}
}
.blog-tag__count {
opacity: 0.7;
font-size: 0.75rem;
}

View File

@@ -0,0 +1,139 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { map } from 'rxjs';
import {
PiNoteComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Locale } from '../core/api';
import type { PublicPage } from '../core/api.types';
import { tagHref, taggedWith, tagsOf } from '../core/blog';
import type { Copy } from '../core/copy';
import { listingPath } from '../core/routes';
import { PostCard } from './post-card';
/**
* The blog: the pages in the panel's "Blog" category, newest first, each
* leading to its own address like any page.
*
* `?tag=` narrows it to one subject. Plain links rather than a client-side
* filter, so a tag's page can be shared and works before any script runs.
*/
@Component({
selector: 'app-blog-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PostCard,
PiNoteComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
],
styleUrl: './blog-page.scss',
template: `
<header class="content-hero">
<div class="landing-shell">
<p class="landing-eyebrow">{{ copy().blog.eyebrow }}</p>
<h1 bold class="content-hero__title">{{ copy().blog.title }}</h1>
<p l pi-color color="grey" class="content-hero__lead">
{{ copy().blog.lead }}
</p>
@if (activeTag(); as tag) {
<p class="blog-tagged">
{{ copy().blog.tagged.replace('{tag}', tag.name) }}
</p>
}
<p class="blog-feed">
<a [href]="'/blog/rss.xml?lang=' + locale()">{{
copy().blog.feed
}}</a>
</p>
</div>
</header>
<div class="landing-shell blog-body">
@if (tags().length > 0) {
<nav class="blog-tags" [attr.aria-label]="copy().blog.tags">
<a
class="blog-tag"
[href]="blogHref()"
[attr.aria-current]="activeSlug() === null ? 'page' : null"
>{{ copy().blog.allTags }}</a
>
@for (tag of tags(); track tag.slug) {
<a
class="blog-tag"
[href]="tagHref(tag.slug)"
[attr.aria-current]="activeSlug() === tag.slug ? 'page' : null"
>{{ tag.name }}
<span class="blog-tag__count">{{ tag.count }}</span></a
>
}
</nav>
}
@if (shown().length > 0) {
<ul class="blog-list">
@for (post of shown(); track post.id) {
<li>
<app-post-card
[post]="post"
[copy]="copy()"
[locale]="locale()"
layout="row"
/>
</li>
}
</ul>
} @else {
<pi-note
status="info"
appearance="fill"
gap="xs"
[title]="activeSlug() ? copy().blog.noneTagged : copy().blog.empty"
[text]="''"
/>
}
</div>
`,
})
export class BlogPage {
readonly posts = input.required<PublicPage[]>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
protected readonly activeSlug = toSignal(
inject(ActivatedRoute).queryParamMap.pipe(
map((params) => params.get('tag')),
),
{ initialValue: null },
);
protected readonly tags = computed(() => tagsOf(this.posts()));
protected readonly activeTag = computed(
() => this.tags().find((tag) => tag.slug === this.activeSlug()) ?? null,
);
protected readonly shown = computed(() =>
taggedWith(this.posts(), this.activeSlug()),
);
protected readonly blogHref = computed(() =>
listingPath('blog', this.locale()),
);
protected tagHref(slug: string): string {
return tagHref(this.locale(), slug);
}
}

View File

@@ -15,6 +15,8 @@ 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';
import { LinkButton } from './link-button';
@Component({
selector: 'app-call-card',
@@ -30,6 +32,8 @@ import type { PublicCall } from '../core/api.types';
PiTextXSmallDirective,
PiTextColorDirective,
PiTextWeightBoldDirective,
TechnologyChip,
LinkButton,
],
template: `
<pi-card
@@ -51,13 +55,19 @@ import type { PublicCall } from '../core/api.types';
<pi-chip size="small" appearance="outline" [label]="label" />
}
@if (call().seniority; as seniority) {
<pi-chip size="small" appearance="outline" [label]="seniority" />
<pi-chip size="small" appearance="outline" [label]="copy().call.seniority[seniority]" />
}
</div>
</pi-card-top-content>
<pi-card-center-content>
<h3 bold class="landing-card__title">{{ call().title }}</h3>
<h3 bold class="landing-card__title">
@if (call().path; as path) {
<a class="landing-card__title-link" [href]="path">{{ call().title }}</a>
} @else {
{{ call().title }}
}
</h3>
@if (call().project?.title; as project) {
<p xs pi-color color="grey">{{ project }}</p>
}
@@ -68,7 +78,10 @@ import type { PublicCall } from '../core/api.types';
<ul class="landing-card__chips">
@for (technology of call().technologies; track technology.id) {
<li>
<pi-chip size="small" appearance="outline" [label]="technology.name" />
<app-technology-chip
[technology]="technology"
[locale]="locale()"
/>
</li>
}
</ul>
@@ -82,14 +95,26 @@ import type { PublicCall } from '../core/api.types';
>{{ copy().labels.closes }} {{ closes }}</span
>
}
<pi-action-button
size="small"
color="primary"
appearance="outline"
[hasIcon]="true"
[text]="copy().labels.apply"
(execute)="apply.emit()"
/>
<!-- To the call's own page and its form when it has one; to the
landing's general form otherwise. -->
@if (call().path; as path) {
<app-link-button
class="landing-card__button-link"
size="small"
appearance="outline"
[href]="path"
[text]="copy().labels.apply"
/>
} @else {
<pi-action-button
size="small"
color="primary"
appearance="outline"
[hasIcon]="true"
[text]="copy().labels.apply"
(execute)="apply.emit()"
/>
}
</div>
</pi-card-bottom-content>
</pi-card>

View File

@@ -0,0 +1,25 @@
@use './detail';
:host {
display: block;
}
// A textarea's line breaks are the writer's paragraphs.
.call-text {
margin: 0;
font-size: 1.0625rem;
line-height: 1.7;
white-space: pre-line;
}
.call-project-link {
color: var(--pi-color-primary);
}
.call-form-lead {
margin: -0.5rem 0 1.5rem;
}
.call-closed-link {
margin: 1rem 0 0;
}

View File

@@ -0,0 +1,225 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
PiChipComponent,
PiNoteComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Locale } from '../core/api';
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.
*
* `description` and `what_you_get` come from textareas, not a rich editor, so
* they are printed as text with their line breaks kept. A call that is no
* longer open still has an address — a link to it may be out there — but no
* form: the API would refuse the application anyway.
*/
@Component({
selector: 'app-call-detail',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ContactForm,
PiChipComponent,
PiNoteComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
TechnologyChip,
],
styleUrl: './call-detail.scss',
template: `
<header class="content-hero">
<div class="landing-shell">
<p class="landing-eyebrow">{{ copy().call.eyebrow }}</p>
<h1 bold class="content-hero__title">{{ call().title }}</h1>
@if (call().project?.title; as project) {
<p l pi-color color="grey" class="content-hero__lead">
@if (call().project.path; as path) {
<a class="call-project-link" [href]="path">{{ project }}</a>
} @else {
{{ project }}
}
</p>
}
<ul class="content-hero__chips">
@if (hours(); as label) {
<li>
<pi-chip
size="small"
color="accent"
icon="time-detail"
[label]="label"
/>
</li>
}
@if (weeks(); as label) {
<li>
<pi-chip size="small" appearance="outline" [label]="label" />
</li>
}
@if (call().seniority; as seniority) {
<li>
<pi-chip
size="small"
appearance="outline"
[label]="copy().call.seniority[seniority]"
/>
</li>
}
@for (technology of call().technologies ?? []; track technology.id) {
<li>
<app-technology-chip
[technology]="technology"
[locale]="locale()"
/>
</li>
}
</ul>
</div>
</header>
<div class="landing-shell content-page__layout">
<article class="content-page__main">
@if (call().description; as description) {
<p class="call-text">{{ description }}</p>
}
@if (call().what_you_get; as whatYouGet) {
<section class="content-page__section">
<h2>{{ copy().call.whatYouGet }}</h2>
<p class="call-text">{{ whatYouGet }}</p>
</section>
}
<section class="content-page__section" id="apuntate">
@if (isOpen()) {
<h2>{{ copy().call.apply }}</h2>
<p class="content-page__muted call-form-lead">
{{ copy().call.applyLead }}
</p>
<app-contact-form
[copy]="copy()"
[locale]="locale()"
[callSlug]="call().slug"
/>
} @else {
<pi-note
status="info"
appearance="fill"
gap="xs"
[title]="copy().call.unavailable"
[text]="copy().call.unavailableLead"
/>
<p class="call-closed-link">
<a class="call-project-link" [href]="contactHref()"
>{{ copy().nav.contact }} →</a
>
</p>
}
</section>
</article>
<aside class="content-page__aside">
<div class="landing-card content-page__panel">
<dl class="content-page__facts">
@if (commitment(); as commitment) {
<div>
<dt>{{ copy().call.commitment }}</dt>
<dd>{{ commitment }}</dd>
</div>
}
@if (call().seniority; as seniority) {
<div>
<dt>{{ copy().labels.seniority }}</dt>
<dd>{{ copy().call.seniority[seniority] }}</dd>
</div>
}
@if (closesAt(); as closes) {
<div>
<dt>{{ copy().labels.closes }}</dt>
<dd>{{ closes }}</dd>
</div>
}
@if (call().project; as project) {
<div>
<dt>{{ copy().call.project }}</dt>
<dd>
@if (project.path) {
<a [href]="project.path">{{ project.title }}</a>
} @else {
{{ project.title }}
}
</dd>
</div>
}
</dl>
</div>
</aside>
</div>
`,
})
export class CallDetail {
readonly call = input.required<PublicCall>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
protected readonly isOpen = computed(() => this.call().status === 'open');
protected readonly contactHref = computed(
() => `${homeHref(this.locale())}#contacto`,
);
// `pi-chip-value` renders the number and its unit as adjacent spans with no
// separation, so the label is built here, as on the landing's card.
protected readonly hours = computed(() => {
const hours = this.call().hours_per_week;
return hours ? `${hours} ${this.copy().labels.hoursPerWeek}` : null;
});
protected readonly weeks = computed(() => {
const weeks = this.call().duration_weeks;
return weeks ? `${weeks} ${this.copy().labels.weeks}` : null;
});
/** "4 h/semana · 8 semanas · 32 horas en total", with whatever is known. */
protected readonly commitment = computed(() => {
const total = this.call().total_hours;
const parts = [
this.hours(),
this.weeks(),
total ? `${total} ${this.copy().call.totalHours}` : null,
].filter(Boolean);
return parts.length > 0 ? parts.join(' · ') : null;
});
protected readonly closesAt = computed(() => {
const value = this.call().closes_at;
return value
? new Intl.DateTimeFormat(this.locale(), {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(new Date(value))
: null;
});
}

View File

@@ -0,0 +1,71 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { PiTextColorDirective } from '@pi/ui';
import type { Locale } from '../core/api';
import type { Copy } from '../core/copy';
import { homeHref } from '../core/routes';
import { LinkButton } from './link-button';
/**
* "Shall we talk?" at the end of a page's side column, so no page is a dead
* end. The form lives on the landing; this leads to it.
*/
@Component({
selector: 'app-contact-cta',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiTextColorDirective, LinkButton],
template: `
<div class="landing-card contact-cta">
<h2 class="contact-cta__title">{{ copy().page.ctaTitle }}</h2>
<p pi-color color="grey">{{ copy().page.ctaLead }}</p>
<!-- An anchor around the library's button, which renders a <button>:
a real link, so it works before hydration and opens in a new tab. -->
<app-link-button
class="contact-cta__link"
[href]="href()"
[text]="copy().nav.contact"
/>
</div>
`,
styles: `
.contact-cta {
display: block;
padding: 1.5rem;
border-radius: var(--pi-theme-card-radius, 1.5rem);
box-shadow: var(--pi-theme-card-shadow, none);
&:hover {
transform: none;
}
p {
margin: 0.5rem 0 1.25rem;
line-height: 1.6;
}
}
.contact-cta__title {
margin: 0;
font-size: 1.375rem;
font-weight: 700;
}
.contact-cta__link {
display: inline-block;
text-decoration: none;
}
`,
})
export class ContactCta {
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
protected readonly href = computed(
() => `${homeHref(this.locale())}#contacto`,
);
}

View File

@@ -1,7 +1,14 @@
// The gap has to beat the distance from a field to its own error message,
// which Material renders flush underneath it — 0px. At 1.25rem the message
// ended up 6px from the *next* field's label and 0px from its own field, so
// "Falta el nombre" read as the caption of "HORAS POR SEMANA" rather than of
// the field above it. The three lines have to group with the field they belong
// to, which means the space between rows must be visibly larger than the space
// inside one.
.landing-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
gap: 2rem;
}
.landing-form__kind {
@@ -28,3 +35,121 @@
margin: 0;
color: var(--pi-color-warn, #d64545);
}
// The hours field sits inside an @if, and that is what breaks its label.
//
// Angular Material measures an outlined field's floating label once, on init,
// and writes the result to the element as an inline
// `transform: var(--mat-mdc-form-field-label-transform, translateY(-50%) …)`.
// A field created inside a conditional block is measured before it has any
// geometry, so the value it keeps is the unfloated one: the label ends up 5px
// above the wrapper instead of 18, straddling the 2px outline and struck
// through by it.
//
// `@pi/ui` already publishes the right geometry for every other field
// (`translateY(-3rem) scale(1)`, a 48px label box), and that rule matches this
// one too — it simply loses to the inline style. `!important` is what beats an
// inline declaration, and it is restating the library's own values, not
// inventing new ones.
:host ::ng-deep .pi-field.pi-form-field__number {
.mdc-notched-outline .mdc-floating-label {
height: var(--pi-spacing-xxxx-large) !important;
}
.mdc-text-field:not(.mat-form-field-no-animations) .mdc-floating-label {
transform: translateY(-3rem) scale(1) !important;
}
}
// Two things the library gets wrong once a field is in a real state.
//
// 1. The label changes shape when you focus it. `@pi/ui` applies the theme's
// label typography under `:not(.mdc-text-field--focused)`, so text-transform,
// letter-spacing and weight all fall away the moment the field takes focus:
// "MENSAJE *" becomes "Mensaje *" in a lighter face, and the label visibly
// reflows under the cursor. Restated here without that guard. Colour is
// deliberately left alone — Material tints the label on focus, and that part
// is wanted.
//
// 2. The focus ring is a stadium on the textarea. Both the notched outline and
// the gradient ring take their radius from `--pi-theme-field-radius`, which
// the `alternative` shell sets to 999px. That is right for a 48px-tall input
// and wrong for a 112px-tall textarea: the two shapes stop coinciding and
// the field is drawn twice, once in the error colour and once in the
// gradient, offset from each other. A fixed radius makes them agree again.
:host ::ng-deep {
.pi-field .mdc-text-field:not(.mdc-text-field--disabled) .mdc-floating-label {
text-transform: var(--pi-theme-field-label-transform, none);
letter-spacing: var(--pi-theme-field-label-letter-spacing, normal);
font-weight: var(--pi-theme-field-label-weight, inherit);
}
// A stadium is right for a 48px input and wrong for a 112px textarea, which
// is all this is now — with the focus ring gone there is no second shape to
// agree with.
.pi-field.pi-form-field__textarea {
--pi-theme-field-radius: 1.25rem;
}
}
// Error states, which move the form around and read wrong.
//
// 1. Submitting an incomplete form shoved everything below it downwards.
// Material's subscript strip is 10px tall while a field is valid and 24px
// once it carries a message, so every errored field grew the form by 14px at
// once — the fields under the cursor jumped out from under it. Reserving the
// taller height up front costs 14px of whitespace and buys a form that does
// not move.
//
// 2. `pi-checkbox` renders its message as an inline span straight after the
// label, so it ran on as part of the sentence: "Acepto que guardéis mis
// datos para responderme. Hace falta tu consentimiento". It belongs on its
// own line, like every other field's message.
:host ::ng-deep {
.pi-field .mat-mdc-form-field-subscript-wrapper {
min-height: 1.5rem;
}
// Its own line, in space reserved for it. Left in flow it would push the
// submit button 25px down at the exact moment you press it — the same jump
// the reserved subscript above removes for the fields.
.pi-checkbox {
position: relative;
padding-bottom: 1.25rem;
}
// Nothing in the library styles this class, so the message inherited the
// body colour and read as ordinary text next to four red ones.
.pi-checkbox__error {
display: block;
position: absolute;
left: 0;
bottom: 0;
color: var(--pi-color-warn);
// `mat-error` renders the other four at 12px; this one inherited 15px.
font-size: var(--pi-font-size-x-small, 0.75rem);
}
}
// TEMPORARY. Remove when @pi/ui ships the fix.
//
// `pi-number-field` declares `<ng-container matPrefix>` unconditionally, so
// Material marks the field as having an icon prefix even though nothing is
// projected into it. The empty prefix is 4px wide and takes the place of the
// 16px inset every other field gets: the value ended up 12px to the left of
// its own label, which is what makes it look wrong.
//
// Only two of the library's twenty fields declare a prefix, both numeric, and
// only `incremental-number-field` actually uses one — so this is an oversight
// rather than a pattern. The fix belongs there and is written:
// `pireon-ui`, `number-field.component.html`, guarded by a `contentChild`.
// This restores the inset here until that version is published.
:host ::ng-deep .pi-form-field__number {
.mat-mdc-form-field-icon-prefix {
display: none;
}
.mat-mdc-form-field-infix {
padding-left: 16px;
}
}

View File

@@ -84,14 +84,17 @@ type Kind = 'contribute' | 'request';
/>
} @else {
<form class="landing-form" (ngSubmit)="send()" novalidate>
<fieldset class="landing-form__kind">
<legend>{{ copy().form.kind }}</legend>
<pi-segmented-control
[ariaLabel]="copy().form.kind"
[options]="kinds()"
[(value)]="kind"
/>
</fieldset>
<!-- An application to a call is always an offer of time. -->
@if (!callSlug()) {
<fieldset class="landing-form__kind">
<legend>{{ copy().form.kind }}</legend>
<pi-segmented-control
[ariaLabel]="copy().form.kind"
[options]="kinds()"
[(value)]="kind"
/>
</fieldset>
}
<div class="landing-form__row">
<pi-text-field [formField]="contact.name" [placeholder]="copy().form.name">
@@ -115,7 +118,17 @@ type Kind = 'contribute' | 'request';
<pi-label [isRequired]="true">{{ copy().form.organisation }}</pi-label>
</pi-text-field>
} @else {
<pi-number-field [formField]="contact.hours_per_week">
<!--
The placeholder is not decoration. Without one, Material leaves
this label in its unfloated position and writes an inline
translateY(-50%) that drops the text straight onto the outline's
top border, struck through by it. Every other field here has a
placeholder, which is why this was the only one that looked wrong.
-->
<pi-number-field
[formField]="contact.hours_per_week"
[placeholder]="copy().form.hoursHint"
>
<pi-label>{{ copy().form.hours }}</pi-label>
</pi-number-field>
}
@@ -167,6 +180,8 @@ export class ContactForm {
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
readonly enabled = input(true);
/** Set, the form applies to that call instead of writing in generally. */
readonly callSlug = input<string | null>(null);
private readonly http = inject(HttpClient);
@@ -295,10 +310,17 @@ export class ContactForm {
});
}
private endpoint(): string {
const slug = this.callSlug();
const path = slug ? `/api/calls/${encodeURIComponent(slug)}/apply` : '/api/contact';
return `${path}?lang=${this.locale()}`;
}
private post(payload: unknown): Promise<SubmissionReceipt> {
return new Promise((resolve, reject) => {
this.http
.post<SubmissionReceipt>(`/api/contact?lang=${this.locale()}`, payload)
.post<SubmissionReceipt>(this.endpoint(), payload)
.subscribe({ next: resolve, error: reject });
});
}

View File

@@ -0,0 +1,44 @@
@use './detail';
:host {
display: block;
}
.content-page__eyebrow-link {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.content-page__date {
margin: 1rem 0 0;
font-size: 0.9375rem;
}
.content-page__byline {
color: inherit;
font-weight: 600;
}
.content-page__tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 2.5rem;
}
.content-page__tag {
padding: 0.25rem 0.75rem;
border-radius: 999px;
background: color-mix(in oklab, var(--pi-color-primary) 12%, transparent);
color: inherit;
font-size: 0.875rem;
text-decoration: none;
&:hover {
background: color-mix(in oklab, var(--pi-color-primary) 22%, transparent);
}
}

View File

@@ -0,0 +1,179 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Locale } from '../core/api';
import type { PublicPage } from '../core/api.types';
import { isBlogPost, tagHref } from '../core/blog';
import type { Copy } from '../core/copy';
import { listingPath } from '../core/routes';
import { ApiImage } from './api-image';
import { ContactCta } from './contact-cta';
/**
* One of the pages the panel publishes — "Sobre nosotros", "Contacto".
*
* The body arrives as HTML from the panel's rich editor, written by the people
* who run the site, and goes through Angular's sanitiser on the way in: the
* headings, lists, links and emphasis the editor produces survive it, and
* nothing that could run does.
*/
@Component({
selector: 'app-content-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ApiImage,
ContactCta,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
],
styleUrl: './content-page.scss',
template: `
<header class="content-hero">
<div class="landing-shell">
@if (isPost()) {
<!-- A post leads back to the blog it belongs to. -->
<p class="landing-eyebrow">
<a class="content-page__eyebrow-link" [href]="blogHref()"
>← {{ copy().blog.eyebrow }}</a
>
</p>
} @else if (page().category?.name; as category) {
<p class="landing-eyebrow">{{ category }}</p>
}
<h1 bold class="content-hero__title">{{ page().title }}</h1>
@if (isPost() && page().published_at; as published) {
<p class="content-page__muted content-page__date">
@if (page().byline; as byline) {
{{ copy().blog.by }}
<a class="content-page__byline" [href]="byline.path">{{
byline.name
}}</a>
·
}
{{ copy().blog.published }}
<time [attr.datetime]="published">{{ publishedOn() }}</time>
</p>
}
@if (page().excerpt; as excerpt) {
<p l pi-color color="grey" class="content-hero__lead">
{{ excerpt }}
</p>
}
</div>
</header>
<div class="landing-shell content-page__layout">
<article class="content-page__main">
@if (page().featured_image; as image) {
<figure class="content-page__media">
<app-api-image
[image]="image"
sizes="(min-width: 1024px) 46rem, 100vw"
loading="eager"
imgClass="content-page__image"
/>
</figure>
}
<div class="content-page__body" [innerHTML]="page().body ?? ''"></div>
@if (isPost() && (page().tags ?? []).length > 0) {
<nav class="content-page__tags" [attr.aria-label]="copy().blog.tags">
@for (tag of page().tags; track tag.slug) {
<a class="content-page__tag" [href]="tagHref(tag.slug)"
>#{{ tag.name }}</a
>
}
</nav>
}
</article>
<aside class="content-page__aside">
@if (related().length > 0) {
<nav [attr.aria-label]="copy().page.related">
<p class="landing-eyebrow">{{ copy().page.related }}</p>
<ul class="content-page__related">
@for (item of related(); track item.id) {
<li>
<a class="landing-card content-page__card" [href]="item.path">
<span class="content-page__card-title">{{
item.title
}}</span>
@if (item.excerpt) {
<span class="content-page__card-text">{{
item.excerpt
}}</span>
}
</a>
</li>
}
</ul>
</nav>
}
<app-contact-cta [copy]="copy()" [locale]="locale()" />
</aside>
</div>
`,
})
export class ContentPage {
readonly page = input.required<PublicPage>();
readonly pages = input.required<PublicPage[]>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
protected readonly isPost = computed(() =>
isBlogPost(this.page(), this.locale()),
);
protected readonly blogHref = computed(() =>
listingPath('blog', this.locale()),
);
protected tagHref(slug: string): string {
return tagHref(this.locale(), slug);
}
protected readonly publishedOn = computed(() => {
const published = this.page().published_at;
return published
? new Intl.DateTimeFormat(this.locale(), {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(new Date(published))
: '';
});
/**
* Somewhere to go next, so a page is not a dead end: the rest of its
* category first, then anything else, three at most.
*/
protected readonly related = computed(() => {
const page = this.page();
const others = this.pages().filter(
(other) => other.id !== page.id && other.path && other.title,
);
const sameCategory = (other: PublicPage) =>
other.category?.id === page.category?.id;
return [
...others.filter(sameCategory),
...others.filter((other) => !sameCategory(other)),
].slice(0, 3);
});
}

View File

@@ -0,0 +1,96 @@
@use './detail';
:host {
display: block;
}
.contribute-body {
padding-bottom: 1rem;
}
.contribute-section__head {
max-width: 42rem;
margin-bottom: 1.5rem;
h2 {
margin: 0;
font-size: clamp(1.5rem, 3vw, 1.875rem);
font-weight: 700;
letter-spacing: -0.015em;
}
p {
margin: 0.5rem 0 0;
}
}
.contribute-grid {
display: grid;
gap: 1.25rem;
margin-bottom: 4rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (min-width: 1024px) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
// The shared layout minus its own side padding: this sits inside the shell.
.contribute-layout {
padding-inline: 0;
}
.contribute-issues,
.contribute-steps {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
// `@pi/ui`'s reset spaces every `li + li`; these lists are spaced by `gap`.
> li {
margin: 0;
}
}
.contribute-steps {
counter-reset: step;
gap: 1rem;
li {
position: relative;
display: flex;
flex-direction: column;
gap: 0.125rem;
padding-left: 2.25rem;
counter-increment: step;
font-size: 0.9375rem;
line-height: 1.5;
&::before {
content: counter(step);
position: absolute;
top: 0;
left: 0;
display: grid;
place-items: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: color-mix(in srgb, var(--pi-color-primary) 14%, transparent);
color: var(--pi-color-primary);
font-size: 0.8125rem;
font-weight: 700;
}
}
}
.contribute-other {
margin: 0;
font-size: 0.9375rem;
line-height: 1.6;
}

View File

@@ -0,0 +1,125 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
PiNoteComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Locale } from '../core/api';
import type { PublicCall, PublicIssue } from '../core/api.types';
import type { Copy } from '../core/copy';
import { CallCard } from './call-card';
import { ContactCta } from './contact-cta';
import { IssueRow } from './issue-row';
/**
* Every way in, on one address: the calls that are open, the issues labelled
* for newcomers across the collective's repositories, and how a first
* contribution goes from picking one to seeing it merged.
*
* The guide is always there. With no call open and no issue labelled, it is
* still the answer to "how do I help?", and the contact card says what to do
* next.
*/
@Component({
selector: 'app-contribute-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CallCard,
ContactCta,
IssueRow,
PiNoteComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
],
styleUrl: './contribute-page.scss',
template: `
<header class="content-hero">
<div class="landing-shell">
<p class="landing-eyebrow">{{ copy().contribute.eyebrow }}</p>
<h1 bold class="content-hero__title">{{ copy().contribute.title }}</h1>
<p l pi-color color="grey" class="content-hero__lead">
{{ copy().contribute.lead }}
</p>
</div>
</header>
<div class="landing-shell contribute-body">
@if (calls().length > 0) {
<section class="contribute-section">
<header class="contribute-section__head">
<h2>{{ copy().contribute.calls }}</h2>
<p class="content-page__muted">{{ copy().contribute.callsLead }}</p>
</header>
<div class="contribute-grid">
@for (call of calls(); track call.id) {
<app-call-card
[call]="call"
[copy]="copy()"
[locale]="locale()"
/>
}
</div>
</section>
}
<div class="content-page__layout contribute-layout">
<section class="contribute-section">
<header class="contribute-section__head">
<h2>{{ copy().contribute.issues }}</h2>
<p class="content-page__muted">
{{ copy().contribute.issuesLead }}
</p>
</header>
@if (issues().length > 0) {
<ul class="contribute-issues">
@for (issue of issues(); track issue.url) {
<li><app-issue-row [issue]="issue" [copy]="copy()" /></li>
}
</ul>
} @else {
<pi-note
status="info"
appearance="fill"
gap="xs"
[title]="copy().contribute.noIssues"
[text]="''"
/>
}
</section>
<aside class="content-page__aside">
<div class="landing-card content-page__panel">
<h2>{{ copy().contribute.guide }}</h2>
<ol class="contribute-steps">
@for (step of copy().contribute.steps; track step.title) {
<li>
<strong>{{ step.title }}</strong>
<span class="content-page__muted">{{ step.text }}</span>
</li>
}
</ol>
</div>
<div class="landing-card content-page__panel">
<h2>{{ copy().contribute.otherWays }}</h2>
<p class="contribute-other">
{{ copy().contribute.otherWaysLead }}
</p>
</div>
<app-contact-cta [copy]="copy()" [locale]="locale()" />
</aside>
</div>
</div>
`,
})
export class ContributePage {
readonly calls = input.required<PublicCall[]>();
readonly issues = input.required<PublicIssue[]>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
}

View File

@@ -1,52 +1,13 @@
@use './lights';
:host {
display: block;
}
.landing-hero {
position: relative;
isolation: isolate;
@include lights.glow;
padding-block: 5rem 3rem;
overflow: hidden;
// Two soft lights rather than a hero image: nothing to download, and nothing
// that dates the page when the brand shifts. `isolate` on the section keeps
// the negative z-index inside it instead of behind the page background.
&::before,
&::after {
content: '';
position: absolute;
z-index: -1;
border-radius: 50%;
pointer-events: none;
filter: blur(80px);
}
&::before {
top: -14rem;
left: 50%;
width: 64rem;
height: 38rem;
transform: translateX(-58%);
opacity: 0.5;
background: radial-gradient(
closest-side,
var(--pi-color-primary),
transparent
);
}
&::after {
top: 4rem;
right: -8rem;
width: 26rem;
height: 26rem;
opacity: 0.35;
background: radial-gradient(
closest-side,
var(--pi-color-accent),
transparent
);
}
@media (min-width: 768px) {
padding-block: 7rem 4rem;
@@ -83,3 +44,88 @@
gap: 0.75rem;
margin-top: 2.25rem;
}
// ---------------------------------------------------------------------------
// Entrance.
//
// The first thing on the page, so it plays on load rather than on scroll, and
// in CSS rather than from the directive: it runs whether or not JavaScript
// ever does, and `both` keeps each piece hidden until its turn and shown after.
//
// The stats come last, at 0.8s. `StatGrid` counts its numbers up from zero
// only if it hydrates before then — see `COUNT_UP_BEFORE_MS` there.
// ---------------------------------------------------------------------------
@keyframes hero-in {
from {
opacity: 0;
transform: translateY(22px);
}
}
@media (prefers-reduced-motion: no-preference) {
.landing-hero .landing-eyebrow,
.landing-hero__title,
.landing-hero__lead,
.landing-hero__actions,
app-stat-grid {
animation: hero-in 0.8s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.landing-hero__title {
animation-delay: 0.08s;
}
.landing-hero__lead {
animation-delay: 0.2s;
}
.landing-hero__actions {
animation-delay: 0.32s;
}
app-stat-grid {
display: block;
animation-delay: 0.8s;
}
}
// ---------------------------------------------------------------------------
// The lights drift and fade as the hero scrolls away, tied to the scroll
// itself rather than to a listener. Where the browser has no scroll-driven
// animations the lights simply stay put.
// ---------------------------------------------------------------------------
@keyframes hero-light-a {
to {
opacity: 0.15;
transform: translateX(-58%) translateY(9rem) scale(1.15);
}
}
@keyframes hero-light-b {
to {
opacity: 0.1;
transform: translate(-6rem, 5rem);
}
}
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) {
.landing-hero::before,
.landing-hero::after {
animation-timeline: scroll(root);
animation-range: 0 90vh;
animation-fill-mode: both;
animation-timing-function: linear;
}
.landing-hero::before {
animation-name: hero-light-a;
}
.landing-hero::after {
animation-name: hero-light-b;
}
}
}

View File

@@ -0,0 +1,79 @@
:host {
display: inline-flex;
}
// `@pi/ui` scopes its button rules to the element (`html button:not(…)` in
// `_pi-button.scss`), so an anchor cannot inherit them. Every value below is
// still the library's: these are the same `--pi-theme-button-*` custom
// properties its own rules read, so switching shell or theme moves this button
// too, and dark mode needs nothing of its own.
.link-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-width: 5.5rem;
border: none;
border-radius: var(--pi-theme-button-radius);
line-height: 100%;
text-decoration: none;
letter-spacing: var(--pi-theme-button-letter-spacing);
text-transform: var(--pi-theme-button-text-transform);
font-weight: var(--pi-theme-button-font-weight);
transition: var(--pi-theme-button-transition);
cursor: pointer;
svg {
flex: none;
width: 0.375rem;
height: 0.5625rem;
}
&:focus-visible {
outline: 2px solid var(--pi-color-primary);
outline-offset: 3px;
}
}
.link-button--medium,
.link-button--large {
min-height: var(--pi-theme-button-height);
padding: var(--pi-spacing-xx-small) var(--pi-spacing-x-large);
}
.link-button--small {
min-width: 4.5rem;
padding: var(--pi-spacing-xxxx-small) var(--pi-spacing-medium);
font-size: 0.8125rem;
}
// Filled. The theme may inject a gradient over the flat colour, exactly as it
// does for a real button.
.link-button--fill {
color: var(--pi-color-white);
box-shadow: var(--pi-theme-button-shadow);
&.link-button--primary {
background-color: var(--pi-color-primary);
background-image: var(--pi-theme-button-fill-primary, none);
}
&.link-button--grey {
background-color: var(--pi-color-grey);
}
}
// Outline. The colour is the border and the text; the surface stays clear.
.link-button--outline {
background: none;
&.link-button--primary {
border: 1px solid var(--pi-color-primary);
color: var(--pi-color-primary);
}
&.link-button--grey {
border: 1px solid var(--pi-color-grey-200);
color: var(--pi-color-grey);
}
}

View File

@@ -0,0 +1,73 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
/**
* A link that looks like the library's action button.
*
* `pi-action-button` renders a `<button>`, so wrapping one in an `<a>` puts a
* button inside a link: invalid HTML, and the button swallows the click, so
* nothing navigates. That is what stopped "Web oficial" working, and the same
* pattern sat in five other places.
*
* Making it an anchor is not only about the click. A real link can be opened
* in a new tab, middle-clicked, copied, reached by keyboard and followed by a
* crawler; a button can do none of that.
*
* The library's own button classes are deliberately NOT reused. Its rules are
* scoped to the element — `html button:not(…)` — so on an anchor they
* contribute nothing except `pi-color__primary`, which is a *text* colour and
* actively fights the white a filled button needs. The stylesheet beside this
* file reads the same `--pi-theme-button-*` tokens the library reads instead,
* so a change of shell or theme still reaches this button.
*/
type Size = 'small' | 'medium' | 'large';
type Tone = 'primary' | 'grey';
@Component({
selector: 'app-link-button',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './link-button.scss',
template: `
<a
[class]="classes()"
[href]="href()"
[attr.target]="external() ? '_blank' : null"
[attr.rel]="external() ? 'noopener noreferrer' : null"
>
<span>{{ text() }}</span>
<svg
viewBox="0 0 6 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<g>
<path
d="M1 1C4.5 4 5 4.38484 5 4.5C5 4.61516 4.5 5 1 8"
stroke="currentColor"
stroke-width="1"
/>
</g>
<g>
<path d="M4.5 4.5H0" stroke="currentColor" stroke-width="1" />
</g>
</svg>
</a>
`,
})
export class LinkButton {
readonly href = input.required<string>();
readonly text = input.required<string>();
readonly size = input<Size>('medium');
readonly color = input<Tone>('primary');
readonly appearance = input<'fill' | 'outline'>('fill');
/** Opens in a new tab, for addresses that are not ours. */
readonly external = input(false);
protected readonly classes = computed(() => [
'link-button',
`link-button--${this.size()}`,
`link-button--${this.color()}`,
`link-button--${this.appearance()}`,
]);
}

View File

@@ -54,7 +54,13 @@ import type { PublicMember } from '../core/api.types';
</pi-card-top-content>
<pi-card-center-content>
<h3 bold class="landing-card__title">{{ member().name }}</h3>
<h3 bold class="landing-card__title">
@if (member().path; as path) {
<a class="landing-card__title-link" [href]="path">{{ member().name }}</a>
} @else {
{{ member().name }}
}
</h3>
@if (member().role || member().headline; as subtitle) {
<p s pi-color color="grey">{{ subtitle }}</p>
}
@@ -93,22 +99,24 @@ export class MemberCard {
readonly member = input.required<PublicMember>();
readonly copy = input.required<Copy>();
protected readonly links = computed(() => {
const member = this.member();
const entries = Object.entries(member.links ?? {}).map(([label, url]) => ({
label,
url,
}));
// The username is handed over separately, so build the URL rather than
// hope somebody also filled the links map in.
if (member.github_username && !entries.some((link) => link.label === 'github')) {
entries.unshift({
label: 'github',
url: `https://github.com/${member.github_username}`,
});
}
return entries.slice(0, 3);
});
protected readonly links = computed(() => memberLinks(this.member()).slice(0, 3));
}
/** A member's links, GitHub first when there is a username for it. */
export function memberLinks(member: PublicMember): { label: string; url: string }[] {
const entries = Object.entries(member.links ?? {}).map(([label, url]) => ({
label,
url,
}));
// The username is handed over separately, so build the URL rather than
// hope somebody also filled the links map in.
if (member.github_username && !entries.some((link) => link.label === 'github')) {
entries.unshift({
label: 'github',
url: `https://github.com/${member.github_username}`,
});
}
return entries;
}

View File

@@ -0,0 +1,54 @@
@use './detail';
:host {
display: block;
}
.member-hero {
display: flex;
flex-direction: column;
gap: 1.5rem;
@media (min-width: 640px) {
flex-direction: row;
align-items: center;
gap: 2rem;
}
}
// A textarea's line breaks are the writer's paragraphs.
.member-bio {
margin: 0;
font-size: 1.0625rem;
line-height: 1.7;
white-space: pre-line;
}
.member-projects {
display: grid;
gap: 1.25rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.member-links {
display: grid;
gap: 0.25rem;
margin: 0;
padding: 0;
list-style: none;
text-transform: capitalize;
}
.member-technologies {
margin-top: 0;
}
// `@pi/ui`'s reset spaces every `li + li`; these lists are spaced by `gap`.
.member-links {
> li {
margin: 0;
}
}

View File

@@ -0,0 +1,187 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
PiAvatarComponent,
PiChipComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Locale } from '../core/api';
import type { PublicMember } from '../core/api.types';
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
* work with, and what they have worked on.
*
* The bio is plain text from a textarea, not HTML, so it is printed as text
* with its line breaks kept rather than handed to `innerHTML`.
*/
@Component({
selector: 'app-member-detail',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ContactCta,
PiAvatarComponent,
PiChipComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
ProjectCard,
TechnologyChip,
],
styleUrl: './member-detail.scss',
template: `
<header class="content-hero">
<div class="landing-shell member-hero">
<pi-avatar
class="member-hero__avatar"
size="x-large"
shape="circle"
color="primary"
icon="user"
[image]="member().avatar?.url"
[alt]="member().avatar?.alt"
[name]="member().name"
/>
<div>
<p class="landing-eyebrow">{{ copy().nav.team }}</p>
<h1 bold class="content-hero__title">{{ member().name }}</h1>
@if (member().headline; as headline) {
<p l pi-color color="grey" class="content-hero__lead">
{{ headline }}
</p>
}
@if (member().is_founder || !member().is_current) {
<ul class="content-hero__chips">
@if (member().is_founder) {
<li>
<pi-chip
size="small"
color="primary"
[label]="copy().labels.founder"
/>
</li>
}
@if (!member().is_current) {
<li>
<pi-chip
size="small"
appearance="outline"
[label]="copy().labels.alumni"
/>
</li>
}
</ul>
}
</div>
</div>
</header>
<div class="landing-shell content-page__layout">
<article class="content-page__main">
@if (member().bio; as bio) {
<p class="member-bio">{{ bio }}</p>
}
@if ((member().projects ?? []).length > 0) {
<section class="content-page__section">
<h2>{{ copy().member.projects }}</h2>
<div class="member-projects">
@for (project of member().projects; track project.id) {
<app-project-card
[project]="project"
[copy]="copy()"
[locale]="locale()"
/>
}
</div>
</section>
}
</article>
<aside class="content-page__aside">
@if (since() || links().length > 0) {
<div class="landing-card content-page__panel">
<dl class="content-page__facts">
@if (since(); as since) {
<div>
<dt>{{ copy().member.since }}</dt>
<dd>{{ since }}</dd>
</div>
}
@if (links().length > 0) {
<div>
<dt>{{ copy().member.links }}</dt>
<dd>
<ul class="member-links">
@for (link of links(); track link.url) {
<li>
<a
[href]="link.url"
target="_blank"
rel="noopener me"
>{{ link.label }}</a
>
</li>
}
</ul>
</dd>
</div>
}
</dl>
</div>
}
@if ((member().technologies ?? []).length > 0) {
<div class="landing-card content-page__panel">
<h2>{{ copy().member.technologies }}</h2>
<ul class="content-hero__chips member-technologies">
@for (technology of member().technologies; track technology.id) {
<li>
<app-technology-chip
[technology]="technology"
[locale]="locale()"
/>
</li>
}
</ul>
</div>
}
<app-contact-cta [copy]="copy()" [locale]="locale()" />
</aside>
</div>
`,
})
export class MemberDetail {
readonly member = input.required<PublicMember>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
protected readonly links = computed(() => memberLinks(this.member()));
protected readonly since = computed(() => {
const joined = this.member().joined_at;
return joined
? new Intl.DateTimeFormat(this.locale(), {
month: 'long',
year: 'numeric',
}).format(new Date(joined))
: null;
});
}

View File

@@ -1,7 +1,6 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import type { RouteMeta } from '@analogjs/router';
import { LinkButton } from './link-button';
import {
PiActionButtonComponent,
PiIconComponent,
PiTextColorDirective,
PiTextMediumDirective,
@@ -10,15 +9,10 @@ import {
PiTextXLargeDirective,
} from '@pi/ui';
export const routeMeta: RouteMeta = {
title: 'Pireon — Página no encontrada',
meta: [{ name: 'robots', content: 'noindex' }],
};
/**
* The status itself comes from `src/server/middleware/not-found-status.ts`:
* a page `load` cannot set it, because Analog resolves loaders through an
* internal request whose response never reaches the browser.
* What an address with nothing at it shows. Rendered by `PageShell`, which
* also sets the 404 status, the title and `noindex` — the middleware answers
* 404 up front for addresses no record could ever live at.
*
* Composed from the library's parts rather than from `pi-empty-state`, which
* would have been the obvious fit: it renders its title as an `<h4>`, and a
@@ -32,14 +26,14 @@ export const routeMeta: RouteMeta = {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PiIconComponent,
PiActionButtonComponent,
PiTextXLargeDirective,
PiTextXLargeDirective,
PiTextMediumDirective,
PiTextSmallDirective,
PiTextColorDirective,
PiTextWeightBoldDirective,
LinkButton,
],
styleUrl: './not-found.page.scss',
styleUrl: './not-found.scss',
template: `
<main class="not-found">
<div class="landing-shell not-found__inner">
@@ -53,14 +47,12 @@ export const routeMeta: RouteMeta = {
La dirección puede haber cambiado. Vuelve al inicio y sigue desde ahí.
</p>
<a class="not-found__home" href="/">
<pi-action-button
size="large"
color="primary"
text="Ir al inicio"
[hasIcon]="true"
/>
</a>
<app-link-button
class="not-found__home"
size="large"
href="/"
text="Ir al inicio"
/>
<p s pi-color color="grey" class="not-found__note">
Si has llegado desde un enlace nuestro, cuéntanoslo.
@@ -69,4 +61,4 @@ export const routeMeta: RouteMeta = {
</main>
`,
})
export default class NotFoundPage {}
export class NotFound {}

View File

@@ -0,0 +1,26 @@
@use './lights';
:host {
display: block;
}
.landing-skip {
position: absolute;
left: -9999px;
&:focus {
left: 1rem;
top: 1rem;
z-index: 100;
padding: 0.5rem 1rem;
background: var(--pi-color-white, #fff);
border-radius: 0.5rem;
}
}
// The lights sit on the whole page rather than on its top: on the hero alone
// they end in a hard edge where it does, and here the text starts right there.
// A page is tall enough for them to fade out on their own.
.content-page {
@include lights.glow($overflow: clip);
}

View File

@@ -0,0 +1,271 @@
import { DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
type OnInit,
} from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { injectRequest, injectResponse } from '@analogjs/router/tokens';
import type { Alternates } from '../core/api.types';
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 { homeHref } from '../core/routes';
import { MaintenanceScreen } from './maintenance-screen';
import { NotFound } from './not-found';
import { SiteFooter } from './site-footer';
import { SiteHeader } from './site-header';
/** What the document head says about the record on the page. */
export interface PageSeo {
title: string;
description: string;
path?: string;
alternates: Alternates;
type: 'article' | 'profile' | 'website';
/** The record's own picture, for link previews. The site's logo otherwise. */
image?: string | null;
/** The RSS feed this page belongs to, announced in `<head>`. */
feed?: string;
/** schema.org data for the record; `url` is added here, absolute. */
jsonLd?: Record<string, unknown> | null;
}
/**
* Everything around a page reached by its address: header, footer, the
* landing's lights, and the answers that are not a page at all — maintenance
* and 404.
*
* The record itself is projected in by `RoutedPage`, which knows its type.
* `seo` doubles as "was anything found": null is a 404.
*/
@Component({
selector: 'app-page-shell',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MaintenanceScreen, NotFound, SiteFooter, SiteHeader],
styleUrl: './page-shell.scss',
template: `
@if (data().maintenance; as notice) {
<app-maintenance-screen
[notice]="notice"
[copy]="copy()"
[locale]="data().locale"
/>
} @else if (seo(); as seo) {
<a class="landing-skip" href="#contenido">{{ copy().page.skip }}</a>
<app-site-header
[copy]="copy()"
[locale]="data().locale"
[links]="navLinks()"
[locales]="locales()"
[brandHref]="home()"
[alternates]="seo.alternates"
(navigate)="goHome($event)"
/>
<main id="contenido" class="content-page">
<ng-content />
</main>
<app-site-footer
[copy]="copy()"
[pages]="data().pages"
[locale]="data().locale"
/>
} @else {
<app-not-found />
}
`,
})
export class PageShell implements OnInit {
readonly data = input.required<RoutedPageData>();
readonly seo = input.required<PageSeo | null>();
private readonly document = inject(DOCUMENT);
private readonly title = inject(Title);
private readonly meta = inject(Meta);
/** Absolute, because `hreflang` and `canonical` want one. Null in the browser, where it is not needed. */
private readonly origin = originFromHeaders(injectRequest()?.headers);
/** Taken here, in the injection context; used once the inputs have arrived. */
private readonly response = injectResponse();
protected readonly copy = computed(() => copyFor(this.data().locale));
protected readonly home = computed(() => homeHref(this.data().locale));
protected readonly navLinks = computed(() =>
pageNavLinks(this.data().pages, this.data().locale),
);
protected readonly locales = computed(
() =>
this.data().site?.locales ?? [
{ code: 'es', name: 'Español', flag: '', is_default: true },
],
);
ngOnInit(): void {
this.answerWithStatus();
}
constructor() {
effect(() => {
const { locale, maintenance } = this.data();
const seo = this.seo();
this.document.documentElement.lang = locale;
if (maintenance) {
return;
}
// What the catch-all route says through its `routeMeta`, which only
// applies when the router picked that route — not when this one had to
// fall back to it.
if (!seo) {
this.title.setTitle('Pireon — Página no encontrada');
this.meta.updateTag({ name: 'robots', content: 'noindex' });
setStructuredData(this.document, null);
return;
}
this.title.setTitle(seo.title);
this.meta.updateTag({ name: 'description', content: seo.description });
this.meta.updateTag({ property: 'og:title', content: seo.title });
this.meta.updateTag({
property: 'og:description',
content: seo.description,
});
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.linkFeed(seo.feed);
});
}
/**
* The header's contact button and section anchors point at the landing, not
* at this page — there is no `#contacto` here to scroll to.
*/
protected goHome(anchor: string): void {
this.document.defaultView?.location.assign(`${this.home()}${anchor}`);
}
/**
* 404 when the API has nothing at this address in this language, 503 during
* maintenance. Set on the render request's response, for the reason the
* landing sets its 503 the same way: the one a `load` sees never reaches the
* browser. The middleware leaves record-shaped addresses alone precisely so
* this can decide.
*
* From `ngOnInit`, not the constructor: an input has no value until then,
* and still nothing has been flushed.
*/
private answerWithStatus(): void {
const { maintenance } = this.data();
const response = this.response;
if (!response || response.headersSent) {
return;
}
if (maintenance) {
response.statusCode = 503;
if (maintenance.retry_after) {
response.setHeader('Retry-After', String(maintenance.retry_after));
}
response.setHeader('Cache-Control', 'no-store');
} else if (!this.seo()) {
response.statusCode = 404;
}
}
/** `<link rel="alternate" type="application/rss+xml">`, one at most. */
private linkFeed(feed: string | undefined): void {
this.document.head
.querySelector('link[type="application/rss+xml"]')
?.remove();
if (!feed) {
return;
}
const link = this.document.createElement('link');
link.setAttribute('rel', 'alternate');
link.setAttribute('type', 'application/rss+xml');
link.setAttribute('title', 'Pireon');
link.setAttribute('href', absoluteUrl(this.origin, feed));
this.document.head.appendChild(link);
}
/**
* Only this side renders the record, but only the API knows that
* `/proyectos/x` and `/en/projects/x` are the same one — hence `alternates`.
* Languages without a translation are absent from it rather than pointing at
* a 404.
*/
private linkAlternates(
path: string | undefined,
alternates: Alternates,
): void {
const head = this.document.head;
head
.querySelectorAll(
'link[rel="alternate"][hreflang], link[rel="canonical"]',
)
.forEach((link) => link.remove());
const absolute = (href: string) =>
this.origin ? `${this.origin}${href}` : href;
const add = (attributes: Record<string, string>) => {
const link = this.document.createElement('link');
for (const [name, value] of Object.entries(attributes)) {
link.setAttribute(name, value);
}
head.appendChild(link);
};
if (path) {
add({ rel: 'canonical', href: absolute(path) });
}
for (const [code, href] of Object.entries(alternates)) {
if (href) {
add({ rel: 'alternate', hreflang: code, href: absolute(href) });
}
}
}
}

View File

@@ -0,0 +1,77 @@
:host {
display: block;
height: 100%;
}
.post-card {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
overflow: hidden;
border-radius: var(--pi-theme-card-radius, 1.5rem);
box-shadow: var(--pi-theme-card-shadow, none);
}
// Beside the text rather than above it, and only when there is an image:
// without one the text would sit in the image's column with the rest empty.
:host(.post-card--row) .post-card--media {
@media (min-width: 768px) {
grid-template-rows: none;
grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
}
}
// The `<img>` belongs to `app-api-image`, so a scoped rule cannot reach it.
.post-card__media ::ng-deep img {
display: block;
width: 100%;
height: 100%;
aspect-ratio: 16 / 10;
object-fit: cover;
}
.post-card__body {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.5rem 1.75rem;
}
.post-card__date {
font-size: 0.8125rem;
color: color-mix(in srgb, currentColor 68%, transparent);
}
.post-card__title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.25;
:host(.post-card--row) & {
font-size: 1.375rem;
}
a {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.post-card__excerpt {
margin: 0;
line-height: 1.6;
color: color-mix(in srgb, currentColor 68%, transparent);
}
.post-card__more {
margin-top: auto;
padding-top: 0.5rem;
color: var(--pi-color-primary);
font-weight: 600;
text-decoration: none;
}

View File

@@ -0,0 +1,83 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import type { Locale } from '../core/api';
import type { PublicPage } from '../core/api.types';
import type { Copy } from '../core/copy';
import { ApiImage } from './api-image';
/**
* One blog post as a card: image, date, title, excerpt, and the way in.
*
* `row` on the blog itself, image beside the text, one post a line; `stack`
* in a grid — the landing's latest posts — image above.
*/
@Component({
selector: 'app-post-card',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ApiImage],
styleUrl: './post-card.scss',
host: { '[class.post-card--row]': "layout() === 'row'" },
template: `
<article
class="landing-card post-card"
[class.post-card--media]="!!post().featured_image"
>
@if (post().featured_image; as image) {
<a
class="post-card__media"
[href]="post().path"
tabindex="-1"
aria-hidden="true"
>
<app-api-image
[image]="image"
[sizes]="
layout() === 'row'
? '(min-width: 1024px) 20rem, 100vw'
: '(min-width: 1024px) 24rem, 100vw'
"
/>
</a>
}
<div class="post-card__body">
@if (post().published_at; as published) {
<time class="post-card__date" [attr.datetime]="published">{{
date()
}}</time>
}
<h3 class="post-card__title">
<a [href]="post().path">{{ post().title }}</a>
</h3>
@if (post().excerpt; as excerpt) {
<p class="post-card__excerpt">{{ excerpt }}</p>
}
<a class="post-card__more" [href]="post().path"
>{{ copy().blog.readMore }} →</a
>
</div>
</article>
`,
})
export class PostCard {
readonly post = input.required<PublicPage>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
readonly layout = input<'row' | 'stack'>('stack');
protected readonly date = computed(() => {
const published = this.post().published_at;
return published
? new Intl.DateTimeFormat(this.locale(), {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(new Date(published))
: '';
});
}

View File

@@ -12,10 +12,24 @@ 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. */
export const PROJECT_STATUS_TONES: Record<
PublicProject['status'],
'success' | 'error' | 'pending' | 'info' | 'disabled'
> = {
idea: 'info',
active: 'success',
paused: 'pending',
completed: 'info',
archived: 'disabled',
};
@Component({
selector: 'app-project-card',
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -31,6 +45,7 @@ import { ApiImage } from './api-image';
PiTextSmallDirective,
PiTextColorDirective,
PiTextWeightBoldDirective,
TechnologyChip,
],
template: `
<pi-card
@@ -70,7 +85,13 @@ import { ApiImage } from './api-image';
</pi-card-top-content>
<pi-card-center-content>
<h3 bold class="landing-card__title">{{ project().title }}</h3>
<h3 bold class="landing-card__title">
@if (project().path; as path) {
<a class="landing-card__title-link" [href]="path">{{ project().title }}</a>
} @else {
{{ project().title }}
}
</h3>
@if (project().tagline; as tagline) {
<p s pi-color color="grey">{{ tagline }}</p>
}
@@ -78,7 +99,10 @@ import { ApiImage } from './api-image';
<ul class="landing-card__chips">
@for (technology of technologies(); track technology.id) {
<li>
<pi-chip size="small" appearance="outline" [label]="technology.name" />
<app-technology-chip
[technology]="technology"
[locale]="locale()"
/>
</li>
}
</ul>
@@ -113,6 +137,8 @@ import { ApiImage } from './api-image';
export class ProjectCard {
readonly project = input.required<PublicProject>();
readonly copy = input.required<Copy>();
/** Only to address the technology chips, which link to their own pages. */
readonly locale = input.required<Locale>();
/** Enough to read the stack at a glance; the rest is noise on a card. */
protected readonly technologies = computed(() =>
@@ -130,19 +156,7 @@ export class ProjectCard {
() => (this.project().repositories ?? []).find((repo) => repo.url)?.url ?? null
);
/** The API's five project states onto the chip's five tones. */
protected readonly statusTone = computed(() => {
const tones: Record<
PublicProject['status'],
'success' | 'error' | 'pending' | 'info' | 'disabled'
> = {
idea: 'info',
active: 'success',
paused: 'pending',
completed: 'info',
archived: 'disabled',
};
return tones[this.project().status];
});
protected readonly statusTone = computed(
() => PROJECT_STATUS_TONES[this.project().status]
);
}

View File

@@ -0,0 +1,155 @@
@use './detail';
:host {
display: block;
}
.project-gallery {
display: grid;
gap: 1rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.project-gallery__item {
margin: 0;
figcaption {
margin-top: 0.5rem;
font-size: 0.875rem;
color: color-mix(in srgb, currentColor 68%, transparent);
}
}
// The `<img>` belongs to `app-api-image`, so a scoped rule cannot reach it.
.project-gallery__item ::ng-deep img {
display: block;
width: 100%;
height: auto;
border-radius: var(--pi-border-radius-medium, 0.75rem);
}
.project-team,
.project-repos {
display: grid;
gap: 0.875rem;
margin: 0;
padding: 0;
list-style: none;
}
.project-team__member {
display: flex;
align-items: center;
gap: 0.75rem;
color: inherit;
text-decoration: none;
> span {
display: flex;
flex-direction: column;
font-size: 0.875rem;
}
&:hover .project-team__name {
text-decoration: underline;
}
}
.project-team__name {
font-weight: 600;
font-size: 0.9375rem;
}
.project-repos li {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.25rem;
a {
color: var(--pi-color-primary);
font-weight: 600;
word-break: break-word;
}
}
.project-repos__stats {
font-size: 0.8125rem;
}
// `@pi/ui`'s reset spaces every `li + li`; these lists are spaced by `gap`.
.project-team,
.project-repos {
> li {
margin: 0;
}
}
.project-roadmap,
.project-releases,
.project-first,
.project-proposals {
display: grid;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.project-roadmap__item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.25rem 1rem;
align-items: baseline;
&.is-done a {
text-decoration: line-through;
}
}
.project-roadmap__bar {
grid-column: 1 / -1;
height: 0.375rem;
overflow: hidden;
border-radius: 999px;
background: color-mix(in srgb, currentColor 12%, transparent);
span {
display: block;
height: 100%;
background: var(--pi-color-primary, currentColor);
}
}
.project-releases li {
display: grid;
gap: 0.25rem;
}
.project-releases__notes {
margin: 0;
white-space: pre-wrap;
font: inherit;
font-size: 0.9rem;
}
.project-proposals li {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1rem;
justify-content: space-between;
align-items: baseline;
}
// Clear of the lead above and of the link below.
.project-roadmap,
.project-proposals {
margin-top: 0.75rem;
}
.project-proposals__propose {
margin: 1.25rem 0 0;
}

View File

@@ -0,0 +1,475 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
PiAvatarComponent,
PiChipComponent,
PiChipStatusComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Locale } from '../core/api';
import type { PublicProject } from '../core/api.types';
import type { Copy } from '../core/copy';
import { boardLink, type ProjectBoard } from '../core/taskstream';
import { ApiImage } from './api-image';
import { ContactCta } from './contact-cta';
import { PROJECT_STATUS_TONES } from './project-card';
import { TechnologyChip } from './technology-chip';
import { LinkButton } from './link-button';
/**
* A project on its own address: what it is, why it was built the way it was,
* what went wrong, who made it, and where the code is.
*
* `description`, `decisions` and `postmortem` are HTML from the panel's rich
* editor and go through Angular's sanitiser, as a page's body does.
*/
@Component({
selector: 'app-project-detail',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ApiImage,
ContactCta,
PiAvatarComponent,
PiChipComponent,
PiChipStatusComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
TechnologyChip,
LinkButton,
],
styleUrl: './project-detail.scss',
template: `
<header class="content-hero">
<div class="landing-shell">
<p class="landing-eyebrow">{{ copy().project.details }}</p>
<h1 bold class="content-hero__title">{{ project().title }}</h1>
@if (project().tagline; as tagline) {
<p l pi-color color="grey" class="content-hero__lead">
{{ tagline }}
</p>
}
<ul class="content-hero__chips">
<li>
<pi-chip-status
size="small"
[status]="statusTone()"
[label]="copy().projectStatus[project().status]"
/>
</li>
@if (project().needs_help) {
<li>
<pi-chip
size="small"
appearance="outline"
color="accent"
[label]="copy().labels.needsHelp"
/>
</li>
}
@for (
technology of project().technologies ?? [];
track technology.id
) {
<li>
<app-technology-chip
[technology]="technology"
[locale]="locale()"
/>
</li>
}
</ul>
@if (project().demo_url || repositoryUrl()) {
<div class="content-hero__actions">
@if (project().demo_url; as demo) {
<app-link-button
[href]="demo"
[text]="copy().labels.demo"
[external]="true"
/>
}
@if (repositoryUrl(); as repository) {
<app-link-button
color="grey"
appearance="outline"
[href]="repository"
[text]="copy().labels.repository"
[external]="true"
/>
}
</div>
}
</div>
</header>
<div class="landing-shell content-page__layout">
<article class="content-page__main">
@if (project().cover_image; as cover) {
<figure class="content-page__media">
<app-api-image
[image]="cover"
sizes="(min-width: 1024px) 46rem, 100vw"
loading="eager"
imgClass="content-page__image"
/>
</figure>
}
@if (project().description; as description) {
<div class="content-page__body" [innerHTML]="description"></div>
}
@if (project().decisions; as decisions) {
<section class="content-page__section">
<h2>{{ copy().project.decisions }}</h2>
<div class="content-page__body" [innerHTML]="decisions"></div>
</section>
}
@if (project().postmortem; as postmortem) {
<section class="content-page__section">
<h2>{{ copy().project.postmortem }}</h2>
<div class="content-page__body" [innerHTML]="postmortem"></div>
</section>
}
@if (board(); as b) {
@if (b.showcase.roadmap.length > 0) {
<section class="content-page__section">
<h2>{{ copy().project.roadmap }}</h2>
<p class="content-page__muted">{{ copy().project.roadmapLead }}</p>
<ol class="project-roadmap">
@for (epic of b.showcase.roadmap; track epic.key) {
<li class="project-roadmap__item" [class.is-done]="epic.done">
<a [href]="link(epic.path)" target="_blank" rel="noopener">{{
epic.title
}}</a>
<span class="content-page__muted project-roadmap__when"
>{{ dates(epic.startDate, epic.dueDate) }}
@if (epic.votes && !epic.done) {
· {{ votes(epic.votes) }}
}
</span>
<span
class="project-roadmap__bar"
role="img"
[attr.aria-label]="epic.percent + '%'"
><span [style.width.%]="epic.percent"></span
></span>
</li>
}
</ol>
</section>
}
@if (b.showcase.proposals; as proposals) {
@if (proposals.open || proposals.top.length > 0) {
<section class="content-page__section">
<h2>{{ copy().project.proposals }}</h2>
@if (proposals.top.length > 0) {
<p class="content-page__muted">{{ copy().project.proposalsLead }}</p>
<ol class="project-proposals">
@for (proposal of proposals.top; track proposal.key) {
<li>
<a [href]="link(proposal.path)" target="_blank" rel="noopener">{{
proposal.title
}}</a>
<span class="content-page__muted">{{ votes(proposal.votes) }}</span>
</li>
}
</ol>
}
@if (proposals.open && proposals.path) {
<p class="project-proposals__propose">
<a [href]="link(proposals.path)" target="_blank" rel="noopener"
>{{ copy().project.propose }} →</a
>
</p>
}
</section>
}
}
@if (b.showcase.releases.length > 0) {
<section class="content-page__section">
<h2>{{ copy().project.releases }}</h2>
<ul class="project-releases">
@for (release of b.showcase.releases; track release.name) {
<li>
<strong>{{ release.name }}</strong>
<span class="content-page__muted">
{{
release.status === 'RELEASED'
? copy().project.released
: copy().project.planned
}}
@if (release.releasedAt ?? release.targetDate; as day) {
· {{ dates(day, null) }}
}
@if (release.status !== 'RELEASED') {
· {{ release.percent }}%
}
</span>
@if (release.notes) {
<pre class="project-releases__notes">{{ release.notes }}</pre>
}
</li>
}
</ul>
</section>
}
}
@if ((project().gallery ?? []).length > 0) {
<section class="content-page__section">
<h2>{{ copy().project.gallery }}</h2>
<div class="project-gallery">
@for (image of project().gallery; track image.url) {
<figure class="project-gallery__item">
<app-api-image
[image]="image"
sizes="(min-width: 1024px) 23rem, 100vw"
imgClass="project-gallery__image"
/>
@if (image.caption) {
<figcaption>{{ image.caption }}</figcaption>
}
</figure>
}
</div>
</section>
}
</article>
<aside class="content-page__aside">
<div class="landing-card content-page__panel">
<dl class="content-page__facts">
<div>
<dt>{{ copy().project.status }}</dt>
<dd>{{ copy().projectStatus[project().status] }}</dd>
</div>
@if (project().client; as client) {
<div>
<dt>{{ copy().project.client }}</dt>
<dd>
@if (client.url) {
<a [href]="client.url" target="_blank" rel="noopener">{{
client.name
}}</a>
} @else {
{{ client.name }}
}
</dd>
</div>
}
@if (period(); as period) {
<div>
<dt>{{ copy().project.period }}</dt>
<dd>{{ period }}</dd>
</div>
}
@if (project().lead; as lead) {
<div>
<dt>{{ copy().project.lead }}</dt>
<dd>
<a [href]="lead.path">{{ lead.name }}</a>
</dd>
</div>
}
</dl>
</div>
@if (board(); as b) {
<div class="landing-card content-page__panel">
@if (b.showcase.activeSprint; as sprint) {
<p class="landing-eyebrow">{{ copy().project.sprint }}</p>
<p>
<strong>{{ sprint.name }}</strong> · {{ sprint.percent }}%
@if (sprint.goal) {
<br /><span class="content-page__muted">{{ sprint.goal }}</span>
}
</p>
}
@if (b.showcase.goodFirst.length > 0) {
<h2>{{ copy().project.goodFirst }}</h2>
<p class="content-page__muted">{{ copy().project.goodFirstLead }}</p>
<ul class="project-first">
@for (issue of b.showcase.goodFirst; track issue.key) {
<li>
<a [href]="link(issue.path)" target="_blank" rel="noopener"
>{{ issue.key }} · {{ issue.title }}</a
>
</li>
}
</ul>
}
<a [href]="b.url" target="_blank" rel="noopener">{{
copy().project.board
}} →</a>
</div>
}
@if ((project().calls ?? []).length > 0) {
<nav [attr.aria-label]="copy().project.openCalls">
<p class="landing-eyebrow">{{ copy().project.openCalls }}</p>
<ul class="content-page__related">
@for (call of project().calls; track call.id) {
<li>
<a class="landing-card content-page__card" [href]="call.path">
<span class="content-page__card-title">{{
call.title
}}</span>
@if (call.hours_per_week) {
<span class="content-page__card-text">
{{ call.hours_per_week }}
{{ copy().labels.hoursPerWeek }}
</span>
}
</a>
</li>
}
</ul>
</nav>
}
@if ((project().members ?? []).length > 0) {
<div class="landing-card content-page__panel">
<h2>{{ copy().project.team }}</h2>
<ul class="project-team">
@for (member of project().members; track member.id) {
<li>
<a class="project-team__member" [href]="member.path">
<pi-avatar
size="small"
shape="circle"
color="primary"
icon="user"
[image]="member.avatar?.url"
[alt]="member.avatar?.alt"
[name]="member.name"
/>
<span>
<span class="project-team__name">{{ member.name }}</span>
@if (member.role) {
<span class="content-page__muted">{{
member.role
}}</span>
}
</span>
</a>
</li>
}
</ul>
</div>
}
@if ((project().repositories ?? []).length > 0) {
<div class="landing-card content-page__panel">
<h2>{{ copy().project.repositories }}</h2>
<ul class="project-repos">
@for (repository of project().repositories; track repository.id) {
<li>
@if (repository.url) {
<a [href]="repository.url" target="_blank" rel="noopener">{{
repository.full_name
}}</a>
} @else {
<span>{{ repository.full_name }}</span>
}
<span class="content-page__muted project-repos__stats">
★ {{ repository.stars }} · {{ repository.forks }}
{{ copy().project.forks }}
@if (repository.primary_language) {
· {{ repository.primary_language }}
}
</span>
@if (repository.is_archived) {
<pi-chip
size="small"
appearance="outline"
[label]="copy().project.archived"
/>
} @else if (repository.is_dormant) {
<pi-chip
size="small"
appearance="outline"
[label]="copy().project.dormant"
/>
}
</li>
}
</ul>
</div>
}
<app-contact-cta [copy]="copy()" [locale]="locale()" />
</aside>
</div>
`,
})
export class ProjectDetail {
readonly project = input.required<PublicProject>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
/** Its TaskStream board, when it has one with something to show. */
readonly board = input<ProjectBoard | null>(null);
protected link(path: string): string {
return boardLink(this.board()?.webUrl ?? '', path);
}
/** "1 voto", "3 votos". */
protected votes(count: number): string {
const copy = this.copy().project;
return count === 1 ? copy.voteOne : copy.voteMany.replace('{n}', String(count));
}
/** "3 nov – 20 dic 2026", or one end when only one is known. */
protected dates(start: string | null, end: string | null): string {
const format = (date: string | null) =>
date
? new Intl.DateTimeFormat(this.locale(), {
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'UTC',
}).format(new Date(`${date.slice(0, 10)}T00:00:00Z`))
: null;
return [format(start), format(end)].filter(Boolean).join(' – ');
}
protected readonly statusTone = computed(
() => PROJECT_STATUS_TONES[this.project().status],
);
protected readonly repositoryUrl = computed(
() =>
(this.project().repositories ?? []).find((repository) => repository.url)
?.url ?? null,
);
/** "marzo de 2025 – junio de 2026", or just the start while it runs. */
protected readonly period = computed(() => {
const { started_at, ended_at } = this.project();
const format = (date?: string) =>
date
? new Intl.DateTimeFormat(this.locale(), {
month: 'long',
year: 'numeric',
}).format(new Date(date))
: null;
const [start, end] = [format(started_at), format(ended_at)];
return start && end ? `${start} – ${end}` : (start ?? end);
});
}

View File

@@ -0,0 +1,162 @@
@use './detail';
:host {
display: block;
}
.radar-jump {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.75rem;
a {
text-decoration: none;
}
}
.radar-body {
padding-bottom: 6rem;
}
.radar-ring {
// Clear of the sticky header when reached through an anchor.
scroll-margin-top: 5.5rem;
& + & {
margin-top: 4.5rem;
}
}
.radar-ring__head {
max-width: 42rem;
margin-bottom: 1.75rem;
h2 {
margin: 0;
font-size: clamp(1.75rem, 3.5vw, 2.25rem);
font-weight: 700;
letter-spacing: -0.015em;
}
p {
margin: 0.5rem 0 0;
}
}
.radar-grid {
display: grid;
gap: 1.25rem;
margin: 0;
padding: 0;
list-style: none;
@media (min-width: 640px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (min-width: 1024px) {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
// `@pi/ui`'s reset spaces every `li + li`; this grid is spaced by `gap`.
> li {
margin: 0;
}
}
.radar-card {
display: flex;
flex-direction: column;
gap: 0.875rem;
padding: 1.5rem;
border-radius: var(--pi-theme-card-radius, 1.5rem);
box-shadow: var(--pi-theme-card-shadow, none);
}
.radar-card__top {
display: flex;
align-items: center;
gap: 1rem;
}
// Light in both themes, on purpose: see the component's comment.
.radar-logo {
display: grid;
flex: none;
place-items: center;
width: 3.5rem;
height: 3.5rem;
border-radius: 0.875rem;
background: #fff;
box-shadow:
0 0 0 1px
color-mix(in srgb, var(--brand, var(--pi-color-primary)) 22%, transparent),
0 6px 16px -8px
color-mix(in srgb, var(--brand, var(--pi-color-primary)) 55%, transparent);
}
.radar-logo ::ng-deep img {
display: block;
width: 2.25rem;
height: 2.25rem;
object-fit: contain;
}
.radar-logo__initial {
font-weight: 700;
font-size: 1.25rem;
color: var(--brand, var(--pi-color-primary));
}
.radar-card__name {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.radar-card__category {
margin: 0.125rem 0 0;
font-size: 0.8125rem;
}
.radar-card__text,
.radar-card__note {
margin: 0;
font-size: 0.9375rem;
line-height: 1.6;
}
.radar-card__note {
padding-left: 0.75rem;
border-left: 2px solid var(--pi-color-accent);
font-style: italic;
}
.radar-card__footer {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin: auto 0 0;
font-size: 0.875rem;
a {
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;
}
}

View File

@@ -0,0 +1,170 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
PiChipComponent,
PiNoteComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
} 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<PublicTechnology['ring']>;
const RINGS: Ring[] = ['adopt', 'trial', 'assess', 'hold'];
/**
* The tech radar on its own address: every technology the collective has
* placed, by ring, with its logo, what it is and why it sits where it does.
*
* Logos sit on a light tile in both themes. A brand's own mark is not ours to
* recolour, and some — Rust's, black — would otherwise vanish in dark mode.
*/
@Component({
selector: 'app-radar-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ApiImage,
PiChipComponent,
PiNoteComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
],
styleUrl: './radar-page.scss',
template: `
<header class="content-hero">
<div class="landing-shell">
<p class="landing-eyebrow">{{ copy().radar.eyebrow }}</p>
<h1 bold class="content-hero__title">
{{ copy().sections.radar.title }}
</h1>
<p l pi-color color="grey" class="content-hero__lead">
{{ copy().radar.lead }}
</p>
@if (rings().length > 0) {
<nav class="radar-jump" [attr.aria-label]="copy().radar.eyebrow">
@for (group of rings(); track group.ring) {
<a [href]="'#' + group.ring">
<pi-chip
size="small"
appearance="outline"
[label]="
copy().rings[group.ring] + ' · ' + group.technologies.length
"
/>
</a>
}
</nav>
}
</div>
</header>
<div class="landing-shell radar-body">
@for (group of rings(); track group.ring) {
<section class="radar-ring" [id]="group.ring">
<header class="radar-ring__head">
<h2>{{ copy().rings[group.ring] }}</h2>
<p class="content-page__muted">
{{ copy().radar.ringLead[group.ring] }}
</p>
</header>
<ul class="radar-grid">
@for (technology of group.technologies; track technology.id) {
<li class="landing-card radar-card">
<a class="radar-card__top" [href]="href(technology)">
<span
class="radar-logo"
[style.--brand]="technology.color || null"
>
@if (technology.logo; as logo) {
<app-api-image
[image]="logo"
sizes="48px"
imgClass="radar-logo__image"
/>
} @else {
<span class="radar-logo__initial" aria-hidden="true">{{
technology.name[0]
}}</span>
}
</span>
<div>
<h3 class="radar-card__name">{{ technology.name }}</h3>
<p class="content-page__muted radar-card__category">
{{ copy().radar.categories[technology.category] }}
</p>
</div>
</a>
@if (technology.description; as description) {
<p class="radar-card__text">{{ description }}</p>
}
@if (technology.radar_note; as note) {
<p class="radar-card__note">{{ note }}</p>
}
@if (technology.website || technology.projects_count > 0) {
<p class="radar-card__footer content-page__muted">
@if (technology.website; as website) {
<a [href]="website" target="_blank" rel="noopener">{{
copy().radar.website
}}</a>
}
@if (technology.projects_count > 0) {
<span
>{{ technology.projects_count }}
{{ copy().radar.projects }}</span
>
}
</p>
}
</li>
}
</ul>
</section>
} @empty {
<pi-note
status="info"
appearance="fill"
gap="xs"
[title]="copy().radar.empty"
[text]="''"
/>
}
</div>
`,
})
export class RadarPage {
readonly technologies = input.required<PublicTechnology[]>();
readonly copy = input.required<Copy>();
/** Only to address each technology's own page. */
readonly locale = input.required<Locale>();
/** 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(() =>
RINGS.map((ring) => ({
ring,
technologies: this.technologies().filter(
(technology) => technology.ring === ring,
),
})).filter((group) => group.technologies.length > 0),
);
}

View File

@@ -0,0 +1,324 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { copyFor, type Copy } from '../core/copy';
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';
import { ContentPage } from './content-page';
import { ContributePage } from './contribute-page';
import { MemberDetail } from './member-detail';
import { PageShell, type PageSeo } from './page-shell';
import { ProjectDetail } from './project-detail';
/**
* A page reached by its address, whatever it holds: the shell around it, and
* the body for its type inside.
*/
@Component({
selector: 'app-routed-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
BlogPage,
CallDetail,
ContentPage,
ContributePage,
MemberDetail,
PageShell,
ProjectDetail,
RadarPage,
ServiceDetail,
TechnologyDetail,
],
template: `
<app-page-shell [data]="data()" [seo]="seo()">
@if (data().found; as found) {
@switch (found.type) {
@case ('page') {
<app-content-page
[page]="found.record"
[pages]="data().pages"
[copy]="copy()"
[locale]="data().locale"
/>
}
@case ('project') {
<app-project-detail
[project]="found.record"
[board]="found.board"
[copy]="copy()"
[locale]="data().locale"
/>
}
@case ('call') {
<app-call-detail
[call]="found.record"
[copy]="copy()"
[locale]="data().locale"
/>
}
@case ('service') {
<app-service-detail
[service]="found.record"
[copy]="copy()"
[locale]="data().locale"
/>
}
@case ('blog') {
<app-blog-page
[posts]="found.record"
[copy]="copy()"
[locale]="data().locale"
/>
}
@case ('contribute') {
<app-contribute-page
[calls]="found.record.calls"
[issues]="found.record.issues"
[copy]="copy()"
[locale]="data().locale"
/>
}
@case ('radar') {
<app-radar-page
[technologies]="found.record"
[copy]="copy()"
[locale]="data().locale"
/>
}
@case ('technology') {
<app-technology-detail
[technology]="found.record"
[copy]="copy()"
[locale]="data().locale"
/>
}
@case ('member') {
<app-member-detail
[member]="found.record"
[copy]="copy()"
[locale]="data().locale"
/>
}
}
}
</app-page-shell>
`,
})
export class RoutedPage {
readonly data = input.required<RoutedPageData>();
protected readonly copy = computed(() => copyFor(this.data().locale));
protected readonly seo = computed(() =>
seoFor(this.data().found, this.copy(), this.data().locale),
);
}
/** What each type puts in the document head. Null when nothing was found. */
export function seoFor(
found: RoutedRecord | null,
copy: Copy,
locale: Locale,
): PageSeo | null {
if (!found) {
return null;
}
switch (found.type) {
case 'page': {
const page = found.record;
const post = isBlogPost(page, locale);
return {
title: page.meta_title || `Pireon — ${page.title}`,
description: page.meta_description || page.excerpt || '',
path: page.path,
alternates: page.alternates ?? {},
type: 'article',
image: page.featured_image?.url,
feed: post ? `/blog/rss.xml?lang=${locale}` : undefined,
jsonLd: post
? {
'@type': 'BlogPosting',
headline: page.title,
description: page.excerpt,
inLanguage: page.locale,
datePublished: page.published_at,
dateModified: page.updated_at,
image: page.featured_image?.url,
publisher: { '@type': 'Organization', name: 'Pireon' },
}
: {
'@type': 'WebPage',
name: page.title,
description: page.excerpt,
inLanguage: page.locale,
},
};
}
case 'project': {
const project = found.record;
return {
title: project.meta_title || `Pireon — ${project.title}`,
description: project.meta_description || project.tagline || '',
path: project.path,
alternates: project.alternates ?? {},
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': {
const member = found.record;
return {
title: `Pireon — ${member.name}`,
description: member.headline || member.bio?.slice(0, 160) || '',
path: member.path,
alternates: member.alternates ?? {},
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': {
const service = found.record;
return {
title: service.meta_title || `Pireon — ${service.title}`,
description: service.meta_description || service.tagline || '',
path: service.path,
alternates: service.alternates ?? {},
type: 'article',
jsonLd: {
'@type': 'Service',
name: service.title,
description: service.tagline,
provider: { '@type': 'Organization', name: 'Pireon' },
},
};
}
case 'technology': {
const technology = found.record;
// The only record whose address this site composes rather than reads
// off the payload: the API gives technologies no `path`.
return {
title: `Pireon — ${technology.name}`,
description: technology.description || copy.radar.lead,
path: technologyPath(technology.slug, locale),
alternates: technologyAlternates(technology.slug),
type: 'article',
jsonLd: {
'@type': 'SoftwareApplication',
name: technology.name,
description: technology.description,
applicationCategory: copy.radar.categories[technology.category],
url: technology.website,
},
};
}
case 'blog':
return {
title: `Pireon — ${copy.blog.title}`,
description: copy.blog.lead,
path: listingPath('blog', locale),
alternates: listingAlternates('blog'),
type: 'website',
feed: `/blog/rss.xml?lang=${locale}`,
jsonLd: {
'@type': 'Blog',
name: copy.blog.title,
description: copy.blog.lead,
},
};
case 'contribute':
return {
title: `Pireon — ${copy.contribute.title}`,
description: copy.contribute.lead,
path: listingPath('contribute', locale),
alternates: listingAlternates('contribute'),
type: 'website',
jsonLd: {
'@type': 'WebPage',
name: copy.contribute.title,
description: copy.contribute.lead,
},
};
case 'radar':
return {
title: `Pireon — ${copy.sections.radar.title}`,
description: copy.radar.lead,
path: listingPath('radar', locale),
alternates: listingAlternates('radar'),
type: 'website',
jsonLd: {
'@type': 'WebPage',
name: copy.sections.radar.title,
description: copy.radar.lead,
},
};
case 'call': {
const call = found.record;
const project = call.project?.title;
return {
title: `Pireon — ${call.title}${project ? ` · ${project}` : ''}`,
description: call.description?.slice(0, 160) || '',
path: call.path,
alternates: call.alternates ?? {},
type: 'article',
image: call.project?.cover_image?.url,
jsonLd: {
'@type': 'WebPage',
name: call.title,
description: call.description,
},
};
}
}
}

View File

@@ -44,7 +44,13 @@ import type { PublicService } from '../core/api.types';
</pi-card-top-content>
<pi-card-center-content>
<h3 bold class="landing-card__title">{{ service().title }}</h3>
<h3 bold class="landing-card__title">
@if (service().path; as path) {
<a class="landing-card__title-link" [href]="path">{{ service().title }}</a>
} @else {
{{ service().title }}
}
</h3>
@if (service().tagline; as tagline) {
<p s pi-color color="grey">{{ tagline }}</p>
}

View File

@@ -0,0 +1,31 @@
@use './detail';
:host {
display: block;
}
.service-icon {
display: block;
margin-bottom: 1.25rem;
}
// A textarea's line breaks are the writer's paragraphs.
.service-text {
margin: 0;
font-size: 1.0625rem;
line-height: 1.7;
white-space: pre-line;
}
.service-grid {
display: grid;
gap: 1.25rem;
@media (min-width: 640px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.service-technologies {
margin-top: 0;
}

View File

@@ -0,0 +1,131 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
PiIconComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
} from '@pi/ui';
import type { Locale } from '../core/api';
import type { PublicService } from '../core/api.types';
import type { Copy } from '../core/copy';
import { resolveIcon } from '../core/icons';
import { ContactCta } from './contact-cta';
import { ProjectCard } from './project-card';
import { TestimonialCard } from './testimonial-card';
import { TechnologyChip } from './technology-chip';
/**
* A service on its own address, and the published work that backs it: the
* projects it was delivered on and what the people it was for said.
*
* The description comes from a textarea, so it is printed as text with its
* line breaks kept.
*/
@Component({
selector: 'app-service-detail',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ContactCta,
PiIconComponent,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
ProjectCard,
TestimonialCard,
TechnologyChip,
],
styleUrl: './service-detail.scss',
template: `
<header class="content-hero">
<div class="landing-shell">
<pi-icon
class="service-icon"
[icon]="icon()"
size="x-large"
color="primary"
/>
<p class="landing-eyebrow">{{ copy().service.eyebrow }}</p>
<h1 bold class="content-hero__title">{{ service().title }}</h1>
@if (service().tagline; as tagline) {
<p l pi-color color="grey" class="content-hero__lead">
{{ tagline }}
</p>
}
</div>
</header>
<div class="landing-shell content-page__layout">
<article class="content-page__main">
@if (service().description; as description) {
<p class="service-text">{{ description }}</p>
}
@if ((service().projects ?? []).length > 0) {
<section class="content-page__section">
<h2>{{ copy().service.projects }}</h2>
<div class="service-grid">
@for (project of service().projects; track project.id) {
<app-project-card
[project]="project"
[copy]="copy()"
[locale]="locale()"
/>
}
</div>
</section>
}
@if ((service().testimonials ?? []).length > 0) {
<section class="content-page__section">
<h2>{{ copy().service.testimonials }}</h2>
<div class="service-grid">
@for (
testimonial of service().testimonials;
track testimonial.id
) {
<app-testimonial-card [testimonial]="testimonial" />
}
</div>
</section>
}
</article>
<aside class="content-page__aside">
@if ((service().technologies ?? []).length > 0) {
<div class="landing-card content-page__panel">
<h2>{{ copy().service.technologies }}</h2>
<ul class="content-hero__chips service-technologies">
@for (technology of service().technologies; track technology.id) {
<li>
<app-technology-chip
[technology]="technology"
[locale]="locale()"
/>
</li>
}
</ul>
</div>
}
<app-contact-cta [copy]="copy()" [locale]="locale()" />
</aside>
</div>
`,
})
export class ServiceDetail {
readonly service = input.required<PublicService>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
/** The panel lets anyone type anything here, emoji included. */
protected readonly icon = computed(() =>
resolveIcon(this.service().icon, 'support'),
);
}

View File

@@ -2,19 +2,27 @@
padding-block: 2.5rem 3rem;
}
// One line now. The flex row and its space-between existed to push the credit
// to the far end; with a single child they only complicate the rule.
.landing-footer__row {
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: space-between;
margin-top: 1.5rem;
p {
margin: 0;
}
}
@media (min-width: 768px) {
flex-direction: row;
align-items: center;
.landing-footer__pages {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-top: 1.5rem;
a {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}

View File

@@ -1,11 +1,20 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
PiDividerComponent,
PiTextColorDirective,
PiTextSmallDirective,
} from '@pi/ui';
import type { PublicPage } from '../core/api.types';
import { DEFAULT_LOCALE, type Locale } from '../core/api';
import { isBlogPost } from '../core/blog';
import type { Copy } from '../core/copy';
import { listingPath } from '../core/routes';
@Component({
selector: 'app-site-footer',
@@ -16,16 +25,21 @@ import type { Copy } from '../core/copy';
<footer class="landing-footer">
<div class="landing-shell">
<pi-divider color="grey" width="large" />
@if (links().length > 0) {
<nav
class="landing-footer__pages"
[attr.aria-label]="copy().footer.pages"
>
@for (link of links(); track link.href) {
<a s pi-color color="grey" [href]="link.href">{{ link.label }}</a>
}
</nav>
}
<div class="landing-footer__row">
<p s pi-color color="grey">© {{ year }} Pireon. {{ copy().footer.rights }}</p>
<p s pi-color color="grey">
{{ copy().footer.built }}
<a
href="https://landing-api.pireon.dev/docs/api"
target="_blank"
rel="noopener noreferrer"
>API ↗</a
>
© {{ year }} Pireon. {{ copy().footer.rights }}
</p>
</div>
</div>
@@ -34,6 +48,36 @@ import type { Copy } from '../core/copy';
})
export class SiteFooter {
readonly copy = input.required<Copy>();
/** Every published page — the footer is where the ones not featured in the header are found. */
readonly pages = input<PublicPage[]>([]);
readonly locale = input<Locale>(DEFAULT_LOCALE);
/**
* Every page but the blog's posts, which would grow the footer without end:
* those are one link, to the blog, once there is any.
*/
protected readonly links = computed(() => {
const locale = this.locale();
const pages = this.pages().filter((page) => page.path && page.title);
const hasPosts = pages.some((page) => isBlogPost(page, locale));
return [
...pages
.filter((page) => !isBlogPost(page, locale))
.map((page) => ({
href: page.path as string,
label: page.title as string,
})),
...(hasPosts
? [
{
href: listingPath('blog', locale),
label: this.copy().blog.eyebrow,
},
]
: []),
];
});
protected readonly year = new Date().getFullYear();
}

View File

@@ -6,7 +6,11 @@
position: sticky;
top: 0;
z-index: 50;
background-color: color-mix(in srgb, var(--pi-color-white, #fff) 88%, transparent);
background-color: color-mix(
in srgb,
var(--pi-color-white, #fff) 88%,
transparent
);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--pi-color-grey-200, #e4e4ea);
}
@@ -160,3 +164,24 @@
color: var(--pi-color-grey-600, #5b5b6b);
cursor: pointer;
}
// A shadow under the header once the page moves, so it reads as floating over
// the content rather than as part of it. Tied to the scroll in CSS; without
// scroll-driven animations the border alone does the job, as before.
@keyframes header-lift {
to {
border-bottom-color: transparent;
box-shadow: 0 10px 30px -18px
color-mix(in srgb, var(--pi-color-primary) 45%, transparent);
}
}
@supports (animation-timeline: scroll()) {
@media (prefers-reduced-motion: no-preference) {
.landing-header {
animation: header-lift linear both;
animation-timeline: scroll(root);
animation-range: 0 5rem;
}
}
}

View File

@@ -20,9 +20,14 @@ import { AppearanceService } from '../core/appearance';
import type { Copy } from '../core/copy';
import type { Locale, SitePayload } from '../core/api';
/**
* A section of the landing (`anchor`) or another address altogether (`href`),
* such as one of the pages the panel publishes.
*/
export interface NavLink {
anchor: string;
label: string;
anchor?: string;
href?: string;
}
@Component({
@@ -37,7 +42,7 @@ export interface NavLink {
template: `
<header class="landing-header">
<div class="landing-shell landing-header__inner">
<a class="landing-brand" href="#top">
<a class="landing-brand" [href]="brandHref()">
<img
class="landing-brand__mark"
src="/logo.png"
@@ -50,8 +55,8 @@ export interface NavLink {
</a>
<nav class="landing-header__nav" aria-label="Principal">
@for (link of links(); track link.anchor) {
<a [href]="'#' + link.anchor">{{ link.label }}</a>
@for (link of links(); track link.label) {
<a [href]="hrefOf(link)">{{ link.label }}</a>
}
</nav>
@@ -102,8 +107,8 @@ export interface NavLink {
@if (open()) {
<nav id="mobile-nav" class="landing-header__mobile">
<div class="landing-shell">
@for (link of links(); track link.anchor) {
<a [href]="'#' + link.anchor" (click)="open.set(false)">{{
@for (link of links(); track link.label) {
<a [href]="hrefOf(link)" (click)="open.set(false)">{{
link.label
}}</a>
}
@@ -126,7 +131,7 @@ export interface NavLink {
<div class="landing-header__mobile-locales">
@for (option of locales(); track option.code) {
<a
[href]="'/?lang=' + option.code"
[href]="localeHref(option.code)"
[attr.hreflang]="option.code"
[attr.aria-current]="option.code === locale() ? 'true' : null"
[class.is-active]="option.code === locale()"
@@ -146,6 +151,15 @@ export class SiteHeader {
readonly locale = input.required<Locale>();
readonly links = input.required<NavLink[]>();
readonly locales = input.required<SitePayload['locales']>();
/** Where the logo leads: the top of the landing when on it, the landing itself elsewhere. */
readonly brandHref = input('#top');
/**
* The same page in each language, as the API's `alternates` spells it. Null
* on the landing, which answers on `?lang=` instead. A language missing from
* it has no translation of this page, so the switcher leads to the landing
* in that language rather than to a 404.
*/
readonly alternates = input<Partial<Record<string, string>> | null>(null);
readonly navigate = output<string>();
@@ -171,6 +185,14 @@ export class SiteHeader {
return;
}
this.document.defaultView?.location.assign(`/?lang=${next}`);
this.document.defaultView?.location.assign(this.localeHref(next));
}
protected localeHref(code: string): string {
return this.alternates()?.[code] ?? `/?lang=${code}`;
}
protected hrefOf(link: NavLink): string {
return link.href ?? `#${link.anchor}`;
}
}

View File

@@ -1,13 +1,38 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import {
afterNextRender,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
ElementRef,
inject,
input,
PLATFORM_ID,
signal,
} from '@angular/core';
import { PiStatCardComponent } from '@pi/ui';
import type { Copy } from '../core/copy';
import type { Locale, SitePayload } from '../core/api';
/**
* The hero's stats appear 0.8s after load (`hero.scss`). Counting up is only
* worth it if it can start from zero before anyone has read the number: after
* that, dropping a figure already on screen to 0 is a flicker, not an effect.
*/
const COUNT_UP_BEFORE_MS = 800;
const COUNT_UP_DURATION_MS = 1400;
/**
* The headline numbers from `/site`, as `pi-stat-card`. Zeroes are dropped
* rather than shown: "0 proyectos" says less than nothing, and a young site
* has plenty of them.
*
* The numbers count up from zero as they come into view. The server renders
* the real figures, so without JavaScript — or with reduced motion — they are
* simply there.
*/
@Component({
selector: 'app-stat-grid',
@@ -34,6 +59,20 @@ export class StatGrid {
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
private readonly host = inject(ElementRef<HTMLElement>)
.nativeElement as HTMLElement;
private readonly document = inject(DOCUMENT);
private readonly destroyRef = inject(DestroyRef);
/** 0 → 1 while counting; 1 on the server and whenever there is no animation. */
private readonly progress = signal(1);
constructor() {
if (isPlatformBrowser(inject(PLATFORM_ID))) {
afterNextRender(() => this.countUpWhenSeen());
}
}
protected readonly stats = computed(() => {
const raw = this.site()?.stats ?? {};
const labels = this.copy().stats;
@@ -60,7 +99,70 @@ export class StatGrid {
icon: entry.icon,
color: entry.color,
label: labels[entry.key],
value: format.format(raw[entry.key] ?? 0),
value: format.format(
Math.round((raw[entry.key] ?? 0) * this.progress()),
),
}));
});
private countUpWhenSeen(): void {
const view = this.document.defaultView;
if (
!view ||
typeof IntersectionObserver === 'undefined' ||
view.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
return;
}
const onScreen = this.host.getBoundingClientRect().top < view.innerHeight;
if (onScreen) {
// In the hero at the top of the page: start with its entrance, or not
// at all if the number has already been on screen.
const wait = COUNT_UP_BEFORE_MS - view.performance.now();
if (wait > 0) {
this.progress.set(0);
view.setTimeout(() => this.countUp(view), wait);
}
return;
}
// Further down — a reload that restored the scroll position: count when
// it arrives.
this.progress.set(0);
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
observer.disconnect();
this.countUp(view);
}
},
{ threshold: 0.4 },
);
observer.observe(this.host);
this.destroyRef.onDestroy(() => observer.disconnect());
}
private countUp(view: Window): void {
const started = view.performance.now();
const tick = (now: number) => {
const elapsed = Math.min(1, (now - started) / COUNT_UP_DURATION_MS);
// Ease out: fast at first, settling on the real figure.
this.progress.set(1 - Math.pow(1 - elapsed, 3));
if (elapsed < 1) {
view.requestAnimationFrame(tick);
}
};
view.requestAnimationFrame(tick);
}
}

View File

@@ -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);
}
}

View File

@@ -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 `<span>`, so the anchor goes around it.
*/
@Component({
selector: 'app-technology-chip',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PiChipComponent],
styleUrl: './technology-chip.scss',
template: `
<a class="technology-chip" [href]="href()">
<pi-chip size="small" appearance="outline" [label]="technology().name" />
</a>
`,
})
export class TechnologyChip {
readonly technology = input.required<PublicTechnology>();
readonly locale = input.required<Locale>();
protected readonly href = computed(() =>
technologyPath(this.technology().slug, this.locale()),
);
}

View File

@@ -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);
}

View File

@@ -0,0 +1,163 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import {
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 { LinkButton } from './link-button';
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,
PiTextColorDirective,
PiTextLargeDirective,
PiTextWeightBoldDirective,
LinkButton,
],
styleUrl: './technology-detail.scss',
template: `
<header class="content-hero">
<div class="landing-shell technology-hero">
<span
class="technology-logo"
[style.--brand]="technology().color || null"
>
@if (technology().logo; as logo) {
<app-api-image
[image]="logo"
sizes="72px"
loading="eager"
imgClass="technology-logo__image"
/>
} @else {
<span class="technology-logo__initial" aria-hidden="true">{{
technology().name[0]
}}</span>
}
</span>
<div>
<p class="landing-eyebrow">{{ category() }}</p>
<h1 bold class="content-hero__title">{{ technology().name }}</h1>
@if (technology().description; as description) {
<p l pi-color color="grey" class="content-hero__lead">
{{ description }}
</p>
}
@if (technology().website; as website) {
<p class="content-hero__actions">
<app-link-button
size="large"
[href]="website"
[text]="copy().technology.website"
[external]="true"
/>
</p>
}
</div>
</div>
</header>
<div class="landing-shell content-page__layout">
<div>
@if (ring(); as ring) {
<section class="content-page__section">
<h2>{{ copy().technology.ringTitle }}: {{ ring.label }}</h2>
<p class="technology-text">{{ ring.lead }}</p>
@if (technology().radar_note; as note) {
<p class="technology-text technology-note">{{ note }}</p>
}
</section>
}
<p>
<a [href]="radarHref()">{{ copy().technology.back }} →</a>
</p>
</div>
<aside class="content-page__aside">
@if (facts().length > 0) {
<div class="landing-card content-page__panel">
<ul class="content-page__facts">
@for (fact of facts(); track fact) {
<li>{{ fact }}</li>
}
</ul>
</div>
}
<app-contact-cta [copy]="copy()" [locale]="locale()" />
</aside>
</div>
`,
})
export class TechnologyDetail {
readonly technology = input.required<PublicTechnology>();
readonly copy = input.required<Copy>();
readonly locale = input.required<Locale>();
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;
});
}

View File

@@ -1,12 +1,14 @@
import { defineEventHandler, getRequestURL, setResponseStatus } from 'h3';
import { resolveRoute } from '../../app/core/routes';
/**
* Answers 404 for addresses this app does not serve.
*
* The status cannot be set from the catch-all page's `load`: Analog resolves
* loaders through an internal request, so anything set on that response never
* reaches the browser. Here it can be, and the route table is small enough to
* state outright — the landing is one page.
* state outright — the landing, plus the addresses records live at.
*/
const KNOWN_PATHS = new Set(['/', '/index.html']);
@@ -28,5 +30,13 @@ export default defineEventHandler((event) => {
return;
}
// Shaped like a record's address (`/sobre-nosotros`, `/proyectos/x`,
// `/en/team/x`). Only the API knows whether it exists, and asking it here
// would be a second request for what the page asks anyway — so the page
// sets the 404 itself when the API has nothing there. See `ui/page-shell.ts`.
if (resolveRoute(pathname)) {
return;
}
setResponseStatus(event, 404);
});

View 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);
}
});

View File

@@ -0,0 +1,49 @@
/**
* An application to one call, forwarded to the API for the reason the general
* form is (see `contact.post.ts`).
*
* The slug travels as it came: the API resolves it in the request's language
* and answers 404 for a call that is not open, which is passed on as it is.
*/
import {
defineEventHandler,
getQuery,
getRouterParam,
readBody,
setResponseStatus,
} from 'h3';
import { postSubmission, resolveLocale } from '../../../../../app/core/api';
import {
checkSubmission,
type SubmissionBody,
} from '../../../../../app/core/submission';
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug') ?? '';
const body = (await readBody<SubmissionBody>(event)) ?? {};
const locale = resolveLocale(getQuery(event)['lang']);
const { errors, payload } = checkSubmission(body, { forCall: true });
if (errors) {
setResponseStatus(event, 422);
return { errors, message: 'Validation failed' };
}
try {
const outcome = await postSubmission(
`/calls/${encodeURIComponent(slug)}/apply`,
payload,
locale,
);
setResponseStatus(event, outcome.status);
return outcome.body;
} catch {
setResponseStatus(event, 502);
return { message: 'upstream_unreachable' };
}
});

View File

@@ -1,90 +1,30 @@
/**
* The browser's only write path.
* The general form's write path.
*
* The public API throttles submissions per e-mail address rather than per IP
* precisely because an SSR frontend sends everything from one address, so the
* form posts here and this handler forwards it. Keeping the API host out of
* the browser also keeps CORS out of the picture.
*/
import { defineEventHandler, readBody, setResponseStatus, getQuery } from 'h3';
import { defineEventHandler, getQuery, readBody, setResponseStatus } from 'h3';
import { postSubmission, resolveLocale } from '../../../app/core/api';
import {
postSubmission,
resolveLocale,
type SubmissionPayload,
} from '../../../app/core/api';
interface ContactBody {
name?: string;
email?: string;
message?: string;
organisation?: string | null;
hours_per_week?: number | string | null;
kind?: 'contribute' | 'request';
consent?: boolean;
links?: Record<string, string>;
/** Honeypot. Never rendered visibly; a bot that fills it is filed as spam. */
website?: string | null;
}
checkSubmission,
type SubmissionBody,
} from '../../../app/core/submission';
export default defineEventHandler(async (event) => {
const body = (await readBody<ContactBody>(event)) ?? {};
const body = (await readBody<SubmissionBody>(event)) ?? {};
const locale = resolveLocale(getQuery(event)['lang']);
const kind = body.kind === 'request' ? 'request' : 'contribute';
const { errors, payload } = checkSubmission(body);
const name = body.name?.trim() ?? '';
const email = body.email?.trim() ?? '';
const message = body.message?.trim() ?? '';
const organisation = body.organisation?.trim() ?? '';
const errors: Record<string, string[]> = {};
if (name.length < 2) {
errors['name'] = ['required'];
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
errors['email'] = ['required'];
}
// The API rejects anything shorter, and rightly: a one-word submission is
// never anything but noise.
if (message.length < 20) {
errors['message'] = ['min'];
}
if (kind === 'request' && organisation.length === 0) {
errors['organisation'] = ['required'];
}
if (body.consent !== true) {
errors['consent'] = ['required'];
}
// Caught here rather than spent on a round trip: the API would answer 422
// with the same verdict, and its throttle counts the attempt either way.
if (Object.keys(errors).length > 0) {
if (errors) {
setResponseStatus(event, 422);
return { errors, message: 'Validation failed' };
}
const hours = Number(body.hours_per_week);
const payload: SubmissionPayload = {
name,
email,
message,
consent: true,
kind,
organisation: organisation || null,
hours_per_week: Number.isFinite(hours) && hours > 0 ? hours : null,
...(body.links && Object.keys(body.links).length > 0
? { links: body.links }
: {}),
...(body.website ? { website: body.website } : {}),
};
try {
const outcome = await postSubmission('/contact', payload, locale);

View File

@@ -0,0 +1,54 @@
/**
* `/blog/rss.xml?lang=xx`: the blog as RSS, in one language — a feed reader
* subscribes to one, and a post only has the addresses of the languages it was
* translated into.
*/
import {
defineEventHandler,
getQuery,
getRequestHeaders,
setResponseHeader,
setResponseStatus,
} from 'h3';
import { pireonApi, resolveLocale } from '../../../app/core/api';
import { BLOG_CATEGORY, byPublication } from '../../../app/core/blog';
import { copyFor } from '../../../app/core/copy';
import { originFromHeaders } from '../../../app/core/origin';
import { listingPath } from '../../../app/core/routes';
import { buildFeed } from '../../../app/core/rss';
export default defineEventHandler(async (event) => {
const locale = resolveLocale(getQuery(event)['lang']);
const origin = originFromHeaders(getRequestHeaders(event)) ?? '';
const copy = copyFor(locale);
const { data, error } = await pireonApi.pages(locale, {
category: BLOG_CATEGORY[locale],
per_page: 50,
});
if (error) {
setResponseStatus(event, 503);
setResponseHeader(event, 'Cache-Control', 'no-store');
return 'Feed temporarily unavailable';
}
setResponseHeader(
event,
'Content-Type',
'application/rss+xml; charset=utf-8',
);
return buildFeed(
origin,
locale,
{
title: `Pireon — ${copy.blog.title}`,
description: copy.blog.lead,
link: listingPath('blog', locale),
},
byPublication(data ?? []),
);
});

View 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');
});

View File

@@ -0,0 +1,43 @@
/**
* `/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 { 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)) ?? '';
// 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);
setResponseHeader(event, 'Cache-Control', 'no-store');
return 'Sitemap temporarily unavailable';
}
setResponseHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
// 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 ?? []);
});

View File

@@ -92,19 +92,14 @@ html {
@include brand-ramp('highlighted', #6c2fb5, #ffffff);
}
// The `gradient` theme's signature — a gradient focus ring on fields — is a
// token, so it can be lifted onto the `alternative` shell without swapping
// themes. `_mat-form-field.scss` reads it; the flat border is turned off so it
// does not show through the ring.
.pi-shell__alternative {
--pi-theme-field-focus-gradient: linear-gradient(
120deg,
var(--pi-color-primary),
var(--pi-color-accent),
var(--pi-color-highlighted)
);
--pi-theme-field-border-color-focus: transparent;
}
// The `gradient` theme's focus ring is deliberately NOT lifted onto this shell.
//
// It is drawn as one continuous rounded rectangle over the whole field, while
// the outline underneath is split into three pieces. The leading piece is 12px
// wide, so a large radius clamps to a 12x24 ellipse there while the ring keeps
// a true 24px circle — two curves that cannot be made to agree at any radius,
// showing as a doubled edge down the left of every field. Focus is left to the
// outline's own colour, which is what `alternative` already defines.
html,
body {
@@ -226,6 +221,23 @@ html {
font-size: 1.125rem;
}
// A card's title leads to the record's own page. The card itself stays
// unlinked: it also holds links of its own (demo, repository) and a link
// inside a link is invalid.
.landing-card__button-link {
text-decoration: none;
}
.landing-card__title-link {
color: inherit;
text-decoration: none;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
.landing-card__media {
aspect-ratio: 16 / 9;
overflow: hidden;
@@ -242,10 +254,17 @@ html {
.landing-card__chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.375rem;
margin: 0.75rem 0 0;
padding: 0;
list-style: none;
// `@pi/ui`'s reset gives every `li + li` a top margin — a rhythm for a list
// of prose. In a row spaced by `gap` it dropped every chip but the first.
> li {
margin: 0;
}
}
.landing-card__chips--center {
@@ -453,25 +472,69 @@ html.dark-mode .pi-shell {
// so a visitor without JavaScript — or one whose hydration failed — reads a
// fully visible page instead of a blank column. The media query is a second
// belt: the directive already skips the observer when motion is reduced.
//
// A section does not fade as one block: its heading arrives first, then what
// it holds, and the cards of a grid one after another — so reading down the
// page, each section assembles itself as you reach it.
// ---------------------------------------------------------------------------
html.reveal-ready .reveal {
opacity: 0;
transform: translateY(18px);
transition:
opacity 0.55s ease-out,
transform 0.55s ease-out;
}
$reveal-ease: cubic-bezier(0.22, 1, 0.36, 1);
html.reveal-ready .reveal.is-revealed {
opacity: 1;
transform: none;
html.reveal-ready .reveal {
.landing-section__head,
.landing-section__head ~ *,
.landing-grid > *,
.landing-issues > li {
opacity: 0;
transform: translateY(24px);
transition:
opacity 0.6s ease-out,
transform 0.7s $reveal-ease;
}
// Cards also grow into place, which reads as arriving rather than sliding.
.landing-grid > * {
transform: translateY(28px) scale(0.97);
}
&.is-revealed {
.landing-section__head,
.landing-section__head ~ *,
.landing-grid > *,
.landing-issues > li {
opacity: 1;
transform: none;
}
.landing-section__head ~ * {
transition-delay: 0.12s;
}
// One after another; past the eighth they arrive together, so a long grid
// does not keep the reader waiting for its last row.
@for $i from 1 through 8 {
.landing-grid > :nth-child(#{$i}),
.landing-issues > li:nth-child(#{$i}) {
transition-delay: #{0.15 + ($i - 1) * 0.08}s;
}
}
.landing-grid > :nth-child(n + 9),
.landing-issues > li:nth-child(n + 9) {
transition-delay: 0.8s;
}
}
}
@media (prefers-reduced-motion: reduce) {
html.reveal-ready .reveal {
opacity: 1;
transform: none;
transition: none;
.landing-section__head,
.landing-section__head ~ *,
.landing-grid > *,
.landing-issues > li {
opacity: 1;
transform: none;
transition: none;
}
}
}

9
package-lock.json generated
View File

@@ -22,7 +22,7 @@
"@angular/platform-server": "~22.0.4",
"@angular/router": "~22.0.4",
"@ngx-translate/core": "^18.0.0",
"@pi/ui": "^0.0.2",
"@pi/ui": "^0.0.7",
"front-matter": "^4.0.2",
"marked": "^15.0.7",
"marked-gfm-heading-id": "^4.1.1",
@@ -9587,12 +9587,11 @@
}
},
"node_modules/@pi/ui": {
"version": "0.0.2",
"resolved": "https://nexus.pireon.dev/repository/npm-releases/@pi/ui/-/ui-0.0.2.tgz",
"integrity": "sha1-kCo1VJKozqkA7ag+oOYIRoGESAg=",
"version": "0.0.7",
"resolved": "https://nexus.pireon.dev/repository/npm-releases/@pi/ui/-/ui-0.0.7.tgz",
"integrity": "sha1-PkfcIIYypFMmuypt5C3UbxDNJbc=",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.0",
"luxon": "^3.0.0",
"ngx-infinite-scroll": "^22.0.0",
"tslib": "^2.3.0"

View File

@@ -29,7 +29,7 @@
"@angular/platform-server": "~22.0.4",
"@angular/router": "~22.0.4",
"@ngx-translate/core": "^18.0.0",
"@pi/ui": "^0.0.2",
"@pi/ui": "^0.0.7",
"front-matter": "^4.0.2",
"marked": "^15.0.7",
"marked-gfm-heading-id": "^4.1.1",

52
tools/docker-install-deps.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/bin/sh
# Installs the workspace's dependencies inside the image.
#
# Split out of the Dockerfile so a failure prints this file's own message
# instead of BuildKit echoing a twenty-line inline shell twice.
set -eu
TOKEN_FILE=/run/secrets/nexus_token
NEXUS_PATH=//nexus.pireon.dev/repository/npm-releases/
# `required=true` on the mount only asserts the secret was declared, not that it
# holds anything. An empty one sails past it and surfaces four hundred packages
# later as a Nexus 401 that reads like a credentials problem.
if [ ! -s "$TOKEN_FILE" ]; then
cat >&2 <<'MSG'
ERROR: the nexus_token build secret is empty.
@pi/ui is published to the private Nexus, so the install needs a token.
compose: NEXUS_TOKEN=... docker compose build
or put NEXUS_TOKEN in .env, which compose reads on its own
docker: docker build --secret id=nexus_token,env=NEXUS_TOKEN .
Locally the value is the one already in ~/.npmrc:
grep nexus.pireon.dev ~/.npmrc
MSG
exit 1
fi
# Written and removed inside a single layer, so it leaves no trace even in this
# stage — which is discarded anyway.
NPMRC=/tmp/npmrc
printf '%s:_authToken=%s\n' "$NEXUS_PATH" "$(cat "$TOKEN_FILE")" > "$NPMRC"
if NPM_CONFIG_USERCONFIG="$NPMRC" npm ci --no-audit --no-fund; then
rm -f "$NPMRC"
else
status=$?
rm -f "$NPMRC"
if [ "$status" -ne 0 ]; then
echo "" >&2
echo "npm ci failed. If the error above is a 401 from nexus.pireon.dev," >&2
echo "the token was present but is not valid for npm-releases." >&2
echo "" >&2
fi
exit "$status"
fi

View File

@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="${color}"/><text x="32" y="42" font-family="sans-serif" font-size="30" font-weight="700" fill="#fff" text-anchor="middle">${name[0]}</text></svg>`)}`,
alt: name, width: 64, height: 64, aspect_ratio: 1,
mime: 'image/svg+xml', placeholder: null, srcset: null,
},
});
const technologies = [
@@ -45,7 +54,47 @@ const member = (id, name, slug, founder) => ({
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}`,
});
// A body with everything the editor can produce, images included, so the
// post's typography can be reviewed without writing one in the panel.
const richBody =
'<p>Volvimos a renderizar en el servidor por tres motivos.</p>' +
'<ul><li><p><strong>Rendimiento</strong> en móviles modestos.</p></li><li><p><strong>SEO</strong> sin trucos.</p></li></ul>' +
'<img src="/logo.png" alt="El logo de Pireon">' +
'<h2>Cómo lo hicimos</h2><p>Con <code>injectLoad</code> y un loader por página:</p>' +
'<pre><code>export const load = async () => fetchPage();</code></pre>' +
'<figure><img src="/logo.png" alt="Otra vez el logo"><figcaption>Una imagen con pie de foto.</figcaption></figure>' +
'<blockquote><p>Lo que no se mide en un móvil de gama baja, no está medido.</p></blockquote>';
const post = (id, title, slug, published_at, tags = []) => ({
...page(id, title, slug, false),
body: richBody,
published_at,
tags,
byline: { name: 'Ada Lovelace', slug: 'ada', headline: 'Backend', avatar: null, path: '/equipo/ada' },
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', [{ name: 'Angular', slug: 'angular' }, { name: 'Rendimiento', slug: 'rendimiento' }]),
post(4, 'Nuestro primer año como colectivo', 'primer-ano', '2026-06-01T09:00:00Z', [{ name: 'Colectivo', slug: 'colectivo' }]),
];
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 },
@@ -74,6 +123,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: {} },
@@ -89,12 +139,150 @@ const data = {
],
};
// 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}` },
};
}
// The catalogue has a board in TaskStream: its showcase is served here too,
// so the roadmap, releases, proposals and first issues show without one.
const showcase = {
project: { id: 'ts-1', key: 'CAT', name: 'Catálogo abierto', description: null },
activeSprint: { name: 'Sprint 4', goal: 'Buscar por etiqueta', startDate: '2026-10-05', endDate: '2026-10-19', percent: 40 },
roadmap: [
{ key: 'CAT-1', title: 'Fichas accesibles', startDate: '2026-07-01', dueDate: '2026-09-15', percent: 100, done: true, votes: 2, path: '/p/CAT/issues/CAT-1' },
{ key: 'CAT-7', title: 'Búsqueda por etiquetas', startDate: '2026-09-20', dueDate: '2026-11-30', percent: 45, done: false, votes: 9, path: '/p/CAT/issues/CAT-7' },
{ key: 'CAT-12', title: 'Exportar a PDF', startDate: null, dueDate: '2027-02-01', percent: 0, done: false, votes: 1, path: '/p/CAT/issues/CAT-12' },
],
releases: [
{ name: '1.2', status: 'PLANNED', targetDate: '2026-11-30', releasedAt: null, percent: 60, notes: null },
{ name: '1.1', status: 'RELEASED', targetDate: null, releasedAt: '2026-09-16', percent: 100, notes: '- Fichas accesibles\n- Arreglos en móvil' },
],
goodFirst: [{ key: 'CAT-21', title: 'Traducir los avisos al catalán', type: 't-task', path: '/p/CAT/issues/CAT-21' }],
proposals: {
open: true,
path: '/p/CAT/proponer',
top: [
{ key: 'CAT-30', title: 'Modo oscuro', type: 't-feature', path: '/p/CAT/issues/CAT-30', votes: 14 },
{ key: 'CAT-28', title: 'El buscador no encuentra palabras con tilde', type: 't-bug', path: '/p/CAT/issues/CAT-28', votes: 6 },
{ key: 'CAT-33', title: 'Compartir una ficha por enlace corto', type: 't-feature', path: '/p/CAT/issues/CAT-33', votes: 1 },
],
},
};
data['/public/v1/projects/catalogo'].taskstream = {
key: 'CAT',
url: 'https://taskstream.pireon.dev/p/CAT',
web_url: 'https://taskstream.pireon.dev',
showcase_url: 'http://localhost:4599/taskstream/CAT/showcase',
};
// 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 path = new URL(req.url, 'http://x').pathname.replace(/^\/api/, '');
const payload = data[path];
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/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'));
}
res.setHeader('content-type', 'application/json');
// TaskStream answers its showcase bare, not inside `data`.
if (path === '/taskstream/CAT/showcase') {
res.end(JSON.stringify(showcase));
return;
}
// 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' }));