From 761ddff467cc4fe8304a108b4bd86b1ba670e4a2 Mon Sep 17 00:00:00 2001 From: Yasin Date: Thu, 22 Oct 2026 10:15:57 +0200 Subject: [PATCH 1/5] refactor(copy): move footer, hero, training and research support copy out of components The text editors change was hardcoded in components, so changing an email address or a heading needed a developer. Short plain strings now live in src/data/site.json, checked at build time by a strict schema in src/lib/site.ts: a missing, misspelt or extra key fails the build with the path of the problem. Prose with links or emphasis lives in src/content/blocks//index.mdx and renders through block.astro, which styles links, bold and italics the way the pages did before and keeps links in the same tab. A block that is missing, or a card block without a title, also fails the build. Paths on this site are stored without the base path and get it from the schema. The hero gets its copy as props from index.astro, so neither the schema nor the rest of site.json ends up in the hero's browser bundle. Nothing visible changes: every built page has the same text, links, image sources and alt text as on main, and the home, training, research support and about pages render pixel for pixel the same at 375 and 1440 px in both themes. --- src/components/block.astro | 17 ++ src/components/footer.tsx | 62 ++----- src/components/hero.tsx | 41 ++--- src/components/pricing.tsx | 7 +- .../blocks/research-support-model/index.mdx | 3 + .../index.mdx | 5 + .../blocks/training-domain-specific/index.mdx | 5 + .../index.mdx | 5 + .../blocks/training-tool-specific/index.mdx | 5 + .../training-train-the-trainer/index.mdx | 5 + src/content/config.ts | 11 ++ src/data/site.json | 166 +++++++++++++++++ src/lib/blocks.ts | 15 ++ src/lib/site.ts | 102 ++++++++++ src/pages/index.astro | 3 +- src/pages/research-support.astro | 174 +++++------------- src/pages/training/index.astro | 104 ++++------- 17 files changed, 460 insertions(+), 270 deletions(-) create mode 100644 src/components/block.astro create mode 100644 src/content/blocks/research-support-model/index.mdx create mode 100644 src/content/blocks/research-support-service-agreement/index.mdx create mode 100644 src/content/blocks/training-domain-specific/index.mdx create mode 100644 src/content/blocks/training-research-data-management/index.mdx create mode 100644 src/content/blocks/training-tool-specific/index.mdx create mode 100644 src/content/blocks/training-train-the-trainer/index.mdx create mode 100644 src/data/site.json create mode 100644 src/lib/blocks.ts create mode 100644 src/lib/site.ts diff --git a/src/components/block.astro b/src/components/block.astro new file mode 100644 index 00000000..0b9fcb15 --- /dev/null +++ b/src/components/block.astro @@ -0,0 +1,17 @@ +--- +import { render } from "astro:content"; +import { getBlock } from "../lib/blocks"; + +interface Props { + slug: string; + class?: string; +} + +const { slug, class: className } = Astro.props; +const { Content } = await render(await getBlock(slug)); +--- + +{/* Not elements.tsx: its links all open in a new tab and its lists and headings are article-sized. */} +
+ +
diff --git a/src/components/footer.tsx b/src/components/footer.tsx index 3ab40bdd..20939a13 100644 --- a/src/components/footer.tsx +++ b/src/components/footer.tsx @@ -1,37 +1,16 @@ import { SiLinkedin, SiYoutube, SiFlickr } from "react-icons/si"; import React from "react"; +import { site } from "../lib/site"; const BASE = import.meta.env.BASE_URL.replace(/\/$/, ''); -const footerLinks = [ - { href: `${BASE}/about`, label: "About" }, - { href: `${BASE}/research-support`, label: "Research Support" }, - { href: `${BASE}/services`, label: "Services" }, - { href: `${BASE}/funding-and-projects`, label: "Funding & Projects" }, - { href: `${BASE}/news`, label: "News" }, - { href: `${BASE}/events`, label: "Events" }, - { href: `${BASE}/training`, label: "Training" }, - { href: `${BASE}/accessibility`, label: "Accessibility" }, -]; +const { navigation, careers, partners, funders, fundingNote } = site.footer; +const { supportEmail } = site.contact; const socials = [ - { name: "LinkedIn", href: "https://www.linkedin.com/company/elixir-norway", icon: SiLinkedin }, - { name: "YouTube", href: "https://www.youtube.com/channel/UCvwFIw5HomylguGOGxR8B8w", icon: SiYoutube }, - { name: "Flickr", href: "https://www.flickr.com/photos/elixir-europe/", icon: SiFlickr }, -]; - -const partners = [ - { href: "https://uib.no/", src: `${BASE}/assets/logos/orgs/uib.svg`, alt: "University of Bergen" }, - { href: "https://uio.no/", src: `${BASE}/assets/logos/orgs/uio.svg`, alt: "University of Oslo" }, - { href: "https://uit.no/", src: `${BASE}/assets/logos/orgs/uit.svg`, alt: "UiT The Arctic University of Norway" }, - { href: "https://ntnu.no/", src: `${BASE}/assets/logos/orgs/ntnu.svg`, alt: "NTNU" }, - { href: "https://nmbu.no/", src: `${BASE}/assets/logos/orgs/nmbu.svg`, alt: "NMBU" }, -]; - -const funders = [ - { href: "https://forskningsradet.no/", src: `${BASE}/assets/logos/orgs/nfr.svg`, alt: "Research Council of Norway" }, - { href: "https://www.nordforsk.org", src: `${BASE}/assets/logos/orgs/nordforsk.svg`, alt: "NordForsk" }, - { href: "https://research-and-innovation.ec.europa.eu/", src: `${BASE}/assets/logos/orgs/eu.svg`, alt: "Co-funded by the European Union" }, + { name: "LinkedIn", href: site.footer.socials.linkedin, icon: SiLinkedin }, + { name: "YouTube", href: site.footer.socials.youtube, icon: SiYoutube }, + { name: "Flickr", href: site.footer.socials.flickr, icon: SiFlickr }, ]; const Footer = () => { @@ -93,7 +72,7 @@ const Footer = () => {

Navigation

    - {footerLinks.map(({ href, label }) => ( + {navigation.map(({ href, label }) => (
  • {

    Contact

    @@ -132,17 +111,17 @@ const Footer = () => {

    Partner institutions

    - {partners.map(({ href, src, alt }) => ( + {partners.map(({ name, href, logo }) => ( {alt} @@ -150,17 +129,17 @@ const Footer = () => {

    Funded by

    - {funders.map(({ href, src, alt }) => ( + {funders.map(({ name, href, logo }) => ( {alt} @@ -174,8 +153,7 @@ const Footer = () => {

    - Financed by the Research Council of Norway (grants 208481, 270068, 295932, 350529), - its partner institutions, NordForsk, and co-funded by the European Union. + {fundingNote}

    diff --git a/src/components/hero.tsx b/src/components/hero.tsx index fb720ede..33794796 100644 --- a/src/components/hero.tsx +++ b/src/components/hero.tsx @@ -4,29 +4,22 @@ import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline'; import { PauseIcon, PlayIcon } from '@heroicons/react/20/solid'; import Button from './button'; import HeroVideo from './hero-video'; +import type { HeroCopy } from '../lib/site'; - -const BASE = import.meta.env.BASE_URL.replace(/\/$/, ''); - -// The first phrase is also the static fallback and completes the sr-only heading. -const WORDS = [ - 'life science', 'genomics', 'bioinformatics', 'biomedical', 'proteomics', - 'AI-ready data', 'scientists across Norway', 'discoveries that matter', 'data you can trust', 'understanding life', 'the next generation', -]; const FLIP_MS = 600; // Multi-word phrases stay a little longer than single fields so they can be read. -const dwellMs = (word: string) => (word.includes(' ') && word !== WORDS[0] ? 2600 : 2300); +const dwellMs = (word: string, words: string[]) => (word.includes(' ') && word !== words[0] ? 2600 : 2300); -function RotatingWord({ playing }: { playing: boolean }) { +function RotatingWord({ words, playing }: { words: string[]; playing: boolean }) { const [{ current, leaving }, setFlip] = useState<{ current: number; leaving: number | null }>({ current: 0, leaving: null }); useEffect(() => { if (!playing) return; const id = setTimeout(() => { - setFlip(({ current }) => ({ current: (current + 1) % WORDS.length, leaving: current })); - }, FLIP_MS + dwellMs(WORDS[current])); + setFlip(({ current }) => ({ current: (current + 1) % words.length, leaving: current })); + }, FLIP_MS + dwellMs(words[current], words)); return () => clearTimeout(id); - }, [playing, current]); + }, [playing, current, words]); useEffect(() => { if (leaving === null) return; @@ -36,7 +29,7 @@ function RotatingWord({ playing }: { playing: boolean }) { return (
    diff --git a/src/components/pricing.tsx b/src/components/pricing.tsx index 2863485c..049721a0 100644 --- a/src/components/pricing.tsx +++ b/src/components/pricing.tsx @@ -1,12 +1,13 @@ import { CheckIcon } from '@heroicons/react/24/outline'; import Button from './button'; +import type { PricingTier } from '../lib/site'; -export default function Pricing({ tiers }) { +export default function Pricing({ tiers, email }: { tiers: PricingTier[]; email: string }) { return (
    {tiers.map((tier) => (
    - + +
    - - When a request is submitted, it is broadcast to all members of the Helpdesk. - The request is then picked up by the member with the most expertise on the topic. - We typically reply within 1 working day. + + {howItWorks.body}
    -

    What we offer

    -

    Expertise across bioinformatics, data management, and life science research.

    +

    {offer.heading}

    +

    {offer.intro}

    - -
    -
    -
    -

    Support types

    -
      - {["General advice & experimental design", "Programming & scripting assistance", "Data analysis & interpretation", "Data management & archiving"].map(item => ( -
    • -
    • - ))} -
    -
    - - -
    -
    -
    - - -
    -
    -
    -

    Fields of expertise

    -
      - {["Genome & transcriptome assembly", "Variant calling & annotation", "Gene expression & epigenetics", "Statistical genomics & network analysis", "Cancer, health & marine biology"].map(item => ( -
    • -
    • - ))} -
    -
    + ))}

    Support model

    -
    -

    - Projects that can be solved within a working day are free of charge, - enabled by funding from the Research Council of Norway. For more demanding projects, an hourly rate applies and - you will need to enter an agreement with ELIXIR Norway. -

    -

    - Projects beyond technical assistance may be set up as a research collaboration, - e.g. by providing funding for a percentage of the position of a helpdesk member. -

    -
    +
    -

    - For requests requiring more than a working day, an agreement between the client and the platform - must be made covering service type and cost. If applicable, the agreement should include a plan for - publication and - co-authorship. - Regardless of contribution type, we expect to be included in the - acknowledgements of resulting publications. -

    +
    -

    Pricing

    -

    Transparent pricing based on project scope and duration.

    +

    {pricing.heading}

    +

    {pricing.intro}

    - +
    diff --git a/src/pages/training/index.astro b/src/pages/training/index.astro index b785cf58..d1130edc 100644 --- a/src/pages/training/index.astro +++ b/src/pages/training/index.astro @@ -12,22 +12,30 @@ import { import { TessEvents, TessMaterials } from "../../components/tess-listings"; import Button from "../../components/button.tsx"; import { browseLinks } from "../../lib/tess"; +import Block from "../../components/block.astro"; +import { getBlockTitle } from "../../lib/blocks"; +import { site } from "../../lib/site"; + +const { seo, heading, intro, resources } = site.training; + +const trainingTypes = await Promise.all([ + { slug: "training-research-data-management", Icon: CircleStackIcon }, + { slug: "training-tool-specific", Icon: WrenchScrewdriverIcon }, + { slug: "training-domain-specific", Icon: BeakerIcon }, + { slug: "training-train-the-trainer", Icon: AcademicCapIcon }, +].map(async (type) => ({ ...type, title: await getBlockTitle(type.slug) }))); --- - +

    - Bioinformatics training for every level + {heading}

    - From introductory workshops to advanced domain-specific courses, ELIXIR Norway - provides open-access training to support life science researchers across Norway and internationally. + {intro}

    @@ -36,98 +44,56 @@ import { browseLinks } from "../../lib/tess";

    Types of training

    - -
    -
    -
    -
    -

    Research Data Management

    -
    -

    - General RDM courses covering data collection, metadata management, sensitive data regulations, and FAIR data handling best practices. -

    -
    - - -
    -
    -
    -
    -

    Tool-Specific Courses

    -
    -

    - Hands-on training for NeLS, UseGalaxy, Data Stewardship Wizard, FEGA-Norway, and other tools in the ELIXIR ecosystem. -

    -
    - - -
    -
    -
    -
    -

    Domain-Specific Courses

    -
    -

    - Specialised training in single-cell sequencing, microbiome analysis, biodiversity studies, genome assembly, and more. -

    -
    - - -
    -
    -
    -
    - + ))}
    -

    Find courses and materials

    +

    {resources.heading}

    - Browse our catalogue of available courses, categorised by topic and proficiency level. - All training materials are open-access. + {resources.intro}

    - +
    -

    ELIXIR TeSS

    -

    Course catalogue

    +

    {resources.tess.name}

    +

    {resources.tess.caption}

    - +
    -

    GitHub

    -

    Training materials

    +

    {resources.github.name}

    +

    {resources.github.caption}

    - +
    -

    Zenodo

    -

    Archived materials

    +

    {resources.zenodo.name}

    +

    {resources.zenodo.caption}

    From a594ec2489aef9c4be4e1fdc48d5b5c6eee199ed Mon Sep 17 00:00:00 2001 From: Yasin Date: Thu, 22 Oct 2026 10:16:02 +0200 Subject: [PATCH 2/5] fix(copy): replace the long dashes in the training and research support text The moved text had four em dashes: both pages' search descriptions, the research support intro and the Train-the-Trainer card. I replaced each with a colon, comma or full stop, so editors start from text that follows the house style. --- src/content/blocks/training-train-the-trainer/index.mdx | 2 +- src/data/site.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/content/blocks/training-train-the-trainer/index.mdx b/src/content/blocks/training-train-the-trainer/index.mdx index 128cacc0..42798941 100644 --- a/src/content/blocks/training-train-the-trainer/index.mdx +++ b/src/content/blocks/training-train-the-trainer/index.mdx @@ -2,4 +2,4 @@ title: Train-the-Trainer --- -Part of the [ELIXIR-GOBLET TtT programme](https://elixir-europe.org/platforms/training/train-the-trainer) — learn teaching techniques for productive learner engagement and effective course design. +Part of the [ELIXIR-GOBLET TtT programme](https://elixir-europe.org/platforms/training/train-the-trainer). Learn teaching techniques for productive learner engagement and effective course design. diff --git a/src/data/site.json b/src/data/site.json index a3da6ba4..b8210a97 100644 --- a/src/data/site.json +++ b/src/data/site.json @@ -60,7 +60,7 @@ "training": { "seo": { "title": "Training", - "description": "Open-access bioinformatics training from ELIXIR Norway — introductory workshops to advanced courses in genomics, data management, and computational biology." + "description": "Open-access bioinformatics training from ELIXIR Norway: introductory workshops to advanced courses in genomics, data management, and computational biology." }, "heading": "Bioinformatics training for every level", "intro": "From introductory workshops to advanced domain-specific courses, ELIXIR Norway provides open-access training to support life science researchers across Norway and internationally.", @@ -75,10 +75,10 @@ "researchSupport": { "seo": { "title": "Research Support", - "description": "Expert bioinformatics support for Norwegian life science researchers — from quick consultations to full project collaborations, provided free of charge by ELIXIR Norway." + "description": "Expert bioinformatics support for Norwegian life science researchers, from quick consultations to full project collaborations, provided free of charge by ELIXIR Norway." }, "heading": "Expert bioinformatics help for your research", - "intro": "From quick consultations to long-term collaborations — our team across five universities helps you design experiments, analyse data, and manage your research outputs.", + "intro": "From quick consultations to long-term collaborations, our team across five universities helps you design experiments, analyse data, and manage your research outputs.", "documentation": { "label": "NeLS user documentation", "href": "https://nels-docs.readthedocs.io/en/latest/" }, "howItWorks": { "title": "How does it work?", From 341578977fa8b24d0dff65410daff782d2095f8e Mon Sep 17 00:00:00 2001 From: Yasin Date: Thu, 22 Oct 2026 10:16:41 +0200 Subject: [PATCH 3/5] style(copy): format site.json the way bever writes it Bever rewrites the file with Python's json.dumps(indent=4, ensure_ascii=False). Starting from that layout keeps its first edit to the lines it changes, not the whole file. The training heading's non-breaking spaces are now literal characters instead of \u00a0 escapes; the text is the same. --- src/data/site.json | 126 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 103 insertions(+), 23 deletions(-) diff --git a/src/data/site.json b/src/data/site.json index b8210a97..ba33c2f7 100644 --- a/src/data/site.json +++ b/src/data/site.json @@ -23,37 +23,102 @@ "the next generation" ], "subline": "ELIXIR Norway supports life science researchers with bioinformatics services, data management tools, and secure e-infrastructure. Part of Europe's leading bioinformatics network.", - "primaryAction": { "label": "Explore services", "href": "/services" }, - "secondaryAction": { "label": "Get support", "href": "/research-support" } + "primaryAction": { + "label": "Explore services", + "href": "/services" + }, + "secondaryAction": { + "label": "Get support", + "href": "/research-support" + } }, "footer": { "navigation": [ - { "label": "About", "href": "/about" }, - { "label": "Research Support", "href": "/research-support" }, - { "label": "Services", "href": "/services" }, - { "label": "Funding & Projects", "href": "/funding-and-projects" }, - { "label": "News", "href": "/news" }, - { "label": "Events", "href": "/events" }, - { "label": "Training", "href": "/training" }, - { "label": "Accessibility", "href": "/accessibility" } + { + "label": "About", + "href": "/about" + }, + { + "label": "Research Support", + "href": "/research-support" + }, + { + "label": "Services", + "href": "/services" + }, + { + "label": "Funding & Projects", + "href": "/funding-and-projects" + }, + { + "label": "News", + "href": "/news" + }, + { + "label": "Events", + "href": "/events" + }, + { + "label": "Training", + "href": "/training" + }, + { + "label": "Accessibility", + "href": "/accessibility" + } ], - "careers": { "label": "Careers at ELIXIR", "href": "https://elixir-europe.org/about-us/vacancies" }, + "careers": { + "label": "Careers at ELIXIR", + "href": "https://elixir-europe.org/about-us/vacancies" + }, "socials": { "linkedin": "https://www.linkedin.com/company/elixir-norway", "youtube": "https://www.youtube.com/channel/UCvwFIw5HomylguGOGxR8B8w", "flickr": "https://www.flickr.com/photos/elixir-europe/" }, "partners": [ - { "name": "University of Bergen", "href": "https://uib.no/", "logo": "/assets/logos/orgs/uib.svg" }, - { "name": "University of Oslo", "href": "https://uio.no/", "logo": "/assets/logos/orgs/uio.svg" }, - { "name": "UiT The Arctic University of Norway", "href": "https://uit.no/", "logo": "/assets/logos/orgs/uit.svg" }, - { "name": "NTNU", "href": "https://ntnu.no/", "logo": "/assets/logos/orgs/ntnu.svg" }, - { "name": "NMBU", "href": "https://nmbu.no/", "logo": "/assets/logos/orgs/nmbu.svg" } + { + "name": "University of Bergen", + "href": "https://uib.no/", + "logo": "/assets/logos/orgs/uib.svg" + }, + { + "name": "University of Oslo", + "href": "https://uio.no/", + "logo": "/assets/logos/orgs/uio.svg" + }, + { + "name": "UiT The Arctic University of Norway", + "href": "https://uit.no/", + "logo": "/assets/logos/orgs/uit.svg" + }, + { + "name": "NTNU", + "href": "https://ntnu.no/", + "logo": "/assets/logos/orgs/ntnu.svg" + }, + { + "name": "NMBU", + "href": "https://nmbu.no/", + "logo": "/assets/logos/orgs/nmbu.svg" + } ], "funders": [ - { "name": "Research Council of Norway", "href": "https://forskningsradet.no/", "logo": "/assets/logos/orgs/nfr.svg" }, - { "name": "NordForsk", "href": "https://www.nordforsk.org", "logo": "/assets/logos/orgs/nordforsk.svg" }, - { "name": "Co-funded by the European Union", "href": "https://research-and-innovation.ec.europa.eu/", "logo": "/assets/logos/orgs/eu.svg" } + { + "name": "Research Council of Norway", + "href": "https://forskningsradet.no/", + "logo": "/assets/logos/orgs/nfr.svg" + }, + { + "name": "NordForsk", + "href": "https://www.nordforsk.org", + "logo": "/assets/logos/orgs/nordforsk.svg" + }, + { + "name": "Co-funded by the European Union", + "href": "https://research-and-innovation.ec.europa.eu/", + "logo": "/assets/logos/orgs/eu.svg" + } ], "fundingNote": "Financed by the Research Council of Norway (grants 208481, 270068, 295932, 350529), its partner institutions, NordForsk, and co-funded by the European Union." }, @@ -67,9 +132,21 @@ "resources": { "heading": "Find courses and materials", "intro": "Browse our catalogue of available courses, categorised by topic and proficiency level. All training materials are open-access.", - "tess": { "name": "ELIXIR TeSS", "caption": "Course catalogue", "href": "https://tess.elixir-europe.org/" }, - "github": { "name": "GitHub", "caption": "Training materials", "href": "https://github.com/ELIXIR-Norway-Training" }, - "zenodo": { "name": "Zenodo", "caption": "Archived materials", "href": "https://zenodo.org/communities/elixir-no/records?q=&f=resource_type%3Alesson&f=resource_type%3Apresentation&l=list&p=1&s=10&sort=newest" } + "tess": { + "name": "ELIXIR TeSS", + "caption": "Course catalogue", + "href": "https://tess.elixir-europe.org/" + }, + "github": { + "name": "GitHub", + "caption": "Training materials", + "href": "https://github.com/ELIXIR-Norway-Training" + }, + "zenodo": { + "name": "Zenodo", + "caption": "Archived materials", + "href": "https://zenodo.org/communities/elixir-no/records?q=&f=resource_type%3Alesson&f=resource_type%3Apresentation&l=list&p=1&s=10&sort=newest" + } } }, "researchSupport": { @@ -79,7 +156,10 @@ }, "heading": "Expert bioinformatics help for your research", "intro": "From quick consultations to long-term collaborations, our team across five universities helps you design experiments, analyse data, and manage your research outputs.", - "documentation": { "label": "NeLS user documentation", "href": "https://nels-docs.readthedocs.io/en/latest/" }, + "documentation": { + "label": "NeLS user documentation", + "href": "https://nels-docs.readthedocs.io/en/latest/" + }, "howItWorks": { "title": "How does it work?", "body": "When a request is submitted, it is broadcast to all members of the Helpdesk. The request is then picked up by the member with the most expertise on the topic. We typically reply within 1 working day." From 64ad4355ebd419738565783c96626a0cf90c1ac5 Mon Sep 17 00:00:00 2001 From: Yasin Date: Thu, 22 Oct 2026 11:48:32 +0200 Subject: [PATCH 4/5] fix(copy): reject protocol-relative links in site.json A link or logo starting with // (or /\, which browsers read the same way) passed the check as a path on this site, but the browser would load it from another host. Paths now have to start with a single /. --- src/lib/site.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lib/site.ts b/src/lib/site.ts index fe02777e..8d8dc3b6 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -9,11 +9,13 @@ const BASE = import.meta.env.BASE_URL.replace(/\/$/, ''); const text = z.string().trim().min(1); const email = z.string().email(); -// Paths on this site are stored without the base path and get it here. -const withBase = (value: string) => (value.startsWith('/') ? `${BASE}${value}` : value); -const sitePath = z.string().startsWith('/', 'must be a path on this site, starting with /').transform(withBase); +// Paths on this site are stored without the base path and get it here. A +// leading // (or /\, which browsers read the same way) would point at another host. +const SITE_PATH = /^\/(?![/\\])/; +const withBase = (value: string) => (SITE_PATH.test(value) ? `${BASE}${value}` : value); +const sitePath = z.string().regex(SITE_PATH, 'must be a path on this site, starting with a single /').transform(withBase); const href = z.string() - .regex(/^(https:\/\/|mailto:|\/)/, 'must start with https://, mailto: or /') + .regex(/^(https:\/\/|mailto:|\/(?![/\\]))/, 'must start with https://, mailto: or a single /') .transform(withBase); const link = z.object({ label: text, href }).strict(); From b3e02711f568a1e481b8e982cbec7eafc6509546 Mon Sep 17 00:00:00 2001 From: Yasin Date: Thu, 22 Oct 2026 22:54:22 +0200 Subject: [PATCH 5/5] fix(copy): reject whitespace in site.json links Browsers drop tabs and line breaks from a link before they read it, so "/\t/example.org" got past the single-slash check from the previous commit and still opened example.org. I now refuse any whitespace in links and logo paths; a real URL never needs it, since a space is written %20. The schema is exported so the new test in src/lib/site.test.ts can feed it bad links. Against the previous check, its tab, newline and carriage-return cases fail. --- src/lib/site.test.ts | 49 ++++++++++++++++++++++++++++++++++++++++++++ src/lib/site.ts | 14 ++++++++----- 2 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 src/lib/site.test.ts diff --git a/src/lib/site.test.ts b/src/lib/site.test.ts new file mode 100644 index 00000000..294b42bd --- /dev/null +++ b/src/lib/site.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import raw from '../data/site.json'; +import { schema } from './site'; + +const withCareersHref = (href: string) => ({ + ...raw, + footer: { ...raw.footer, careers: { ...raw.footer.careers, href } }, +}); + +const withPartnerLogo = (logo: string) => ({ + ...raw, + footer: { ...raw.footer, partners: [{ ...raw.footer.partners[0], logo }, ...raw.footer.partners.slice(1)] }, +}); + +describe('site.json links', () => { + it.each([ + '/services', + 'https://example.org/jobs', + 'mailto:contact@elixir.no', + ])('accepts %j', (href) => { + expect(schema.safeParse(withCareersHref(href)).success).toBe(true); + }); + + // Each of these leaves the site: browsers read /\ like //, and drop tabs + // and line breaks before reading the slashes. + it.each([ + '//example.org', + '/\\example.org', + '/\t/example.org', + '/\n/example.org', + '/\r/example.org', + 'http://example.org', + 'javascript:void(0)', + ])('rejects %j', (href) => { + expect(schema.safeParse(withCareersHref(href)).success).toBe(false); + }); + + it('accepts a logo stored on this site', () => { + expect(schema.safeParse(withPartnerLogo('/assets/logos/uib.svg')).success).toBe(true); + }); + + it.each([ + '//example.org/logo.svg', + '/\t/example.org/logo.svg', + 'https://example.org/logo.svg', + ])('rejects the logo %j', (logo) => { + expect(schema.safeParse(withPartnerLogo(logo)).success).toBe(false); + }); +}); diff --git a/src/lib/site.ts b/src/lib/site.ts index 8d8dc3b6..ec072cd6 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -10,12 +10,16 @@ const text = z.string().trim().min(1); const email = z.string().email(); // Paths on this site are stored without the base path and get it here. A -// leading // (or /\, which browsers read the same way) would point at another host. -const SITE_PATH = /^\/(?![/\\])/; +// leading // (or /\, which browsers read the same way) would point at another +// host, and browsers drop tabs and line breaks before reading those slashes, +// so links may not contain whitespace either. +const SITE_PATH = /^\/(?![/\\])\S*$/; const withBase = (value: string) => (SITE_PATH.test(value) ? `${BASE}${value}` : value); -const sitePath = z.string().regex(SITE_PATH, 'must be a path on this site, starting with a single /').transform(withBase); +const sitePath = z.string() + .regex(SITE_PATH, 'must be a path on this site, starting with a single /, with no spaces') + .transform(withBase); const href = z.string() - .regex(/^(https:\/\/|mailto:|\/(?![/\\]))/, 'must start with https://, mailto: or a single /') + .regex(/^(https:\/\/|mailto:|\/(?![/\\]))\S*$/, 'must start with https://, mailto: or a single /, with no spaces') .transform(withBase); const link = z.object({ label: text, href }).strict(); @@ -33,7 +37,7 @@ const tier = z.object({ mostPopular: z.boolean(), }).strict(); -const schema = z.object({ +export const schema = z.object({ contact: z.object({ supportEmail: email, generalEmail: email,