Skip to main content

Contributing Guide

This guide covers everything you need to know before making changes to this codebase.


Prerequisites

Make sure you have the project running locally before contributing. See Getting Started.


Branching strategy

BranchPurpose
mainStable, production-ready code
devActive development — all feature branches merge here
feature/*New features (e.g. feature/add-filter-chips)
bugfix/*Bug fixes (e.g. bugfix/reference-link)

Workflow:

  1. Branch off dev
  2. Make your changes
  3. Open a merge request targeting dev
  4. dev is merged into main for releases

Never commit directly to main or dev.


Code style

The project enforces consistent style through ESLint and Prettier. All rules are applied automatically — you should not need to configure anything manually.

Formatting rules (Prettier)

SettingValue
Print width100 characters
Tab width2 spaces
Trailing commasES5 (objects, arrays — not function parameters)

Linting rules (ESLint)

  • Extends eslint-config-next (Core Web Vitals + TypeScript)
  • Prettier integration (eslint-config-prettier) — no conflicting formatting rules
  • Semicolons are required — the linter will error without them

TypeScript

  • Strict mode is enabled — no implicit any, no unchecked nulls
  • All new code must be fully typed
  • Use the existing types in src/types/ before defining new ones

Running checks

# Check everything at once (ESLint + TypeScript + Prettier)
pnpm check

# Auto-fix ESLint issues
pnpm lint

# Auto-format with Prettier
pnpm prettier

Run pnpm check before every commit and ensure it passes with no errors.


Pre-commit hook

The project uses Husky for git hooks. The pre-commit hook is configured at .husky/pre-commit. Check its current contents to see what runs automatically on commit.


Writing and running tests

Tests live in src/app/api/__tests__/ alongside the API code they test.

Test structure

src/app/api/__tests__/
├── api/ # One test file per API endpoint
│ ├── search.test.ts
│ ├── categories.test.ts
│ ├── details.test.ts
│ ├── sources.test.ts
│ └── health.test.ts
├── utils/ # Tests for utility functions
│ └── sourceRetriever.test.ts
├── __mocks__/ # Shared test infrastructure
│ ├── opensearchClient.ts # Manual OpenSearch mock
│ └── testServer.ts # Mock Next.js request helper
└── expectedResults/ # JSON fixtures for expected API responses
├── search/
├── categories/
├── details/
└── sources/

And raw OpenSearch mock response fixtures live in:

src/app/api/utils/__mocks__/mockResults/

How tests work

The OpenSearch client is mocked at the module level — no real OpenSearch connection is needed to run tests.

jest.mock("@api/utils/client");

const mock = getOpensearchMock();

// Register a mock response before the test
mock.add({ method: "POST", path: "/hkg-dataset/_search" }, () => mockResponseData);

// After each test, clear registered responses
afterEach(() => mock.clearAll());

Expected outputs are stored as JSON fixtures in expectedResults/. This makes it easy to see what a test expects without reading a wall of inline JSON.

Adding a test for a new endpoint

  1. Create a file src/app/api/__tests__/api/[endpoint].test.ts
  2. Mock the OpenSearch client: jest.mock("@api/utils/client")
  3. Add mock OpenSearch responses to src/app/api/utils/__mocks__/mockResults/[endpoint]/
  4. Add expected API response fixtures to src/app/api/__tests__/expectedResults/[endpoint]/
  5. Use createMockNextRequestWithParams from __mocks__/testServer.ts to build mock requests

Running tests

pnpm test             # Run all tests once
pnpm test:watch # Watch mode — re-runs on file changes

Coverage is collected automatically on every run and written to the coverage/ directory.


Contributing to the UI

Client vs server components

Next.js App Router components are server components by default. Add the "use client" directive at the top of a file only when the component needs:

  • React state (useState, useReducer)
  • Browser APIs or event handlers
  • React Context (useContext)
  • Hooks like useRouter, useSearchParams, useTranslations

When in doubt, keep components as server components and push interactivity down to the smallest possible child.

Component conventions

  • All components are functional and written in TypeScript
  • Define props as a named type Props = { ... } at the top of the file
  • Export the component as the default export
  • Keep components small and focused — split into sub-components when a single file grows large
// Good: typed props, default export
type Props = {
item: SchemaOrgResultItem;
isDetailsCard?: boolean;
};

const ResultItem = ({ item, isDetailsCard }: Props) => (
// ...
);

export default ResultItem;

Where to put new components

TypeLocation
Feature-specific UIsrc/components/app/
Page-level layout (header, footer, error, spinner)src/components/layout/
Reusable primitive (a generic link, button, etc.)src/components/

Mirror the existing folder structure for results-related components:

src/components/app/results/
└── ListResults/
├── ResultItem.tsx ← top-level card
└── ResultItem/ ← sub-components for this card
├── ResultItemHeader.tsx
├── ResultItemInfo.tsx
└── ResultItemFooter.tsx

Styling with Tailwind CSS

All styling uses Tailwind CSS utility classes. The project uses a custom Helmholtz colour palette — use these tokens instead of raw colour values:

TokenUsage
primary-helmholtz-dunkelblauPrimary dark blue (text, borders)
primary-helmholtz-hellblauLight blue (hover states)
primary-helmholtz-weissWhite backgrounds
secondary-helmholtz-mintMint green (CTAs, buttons)
secondary-helmholtz-highlightblauHighlight blue (shadows)

Do not add inline styles or custom CSS unless Tailwind cannot achieve the result.

Adding a new page

Pages live under src/app/[locale]/. To add a new page:

  1. Create a folder matching the URL path, e.g. src/app/[locale]/my-page/
  2. Add a page.tsx file inside it
  3. If the page needs a loading or error state, add loading.tsx or error.tsx alongside it

The [locale] segment is handled automatically by next-intl — you do not need to handle it manually.

Adding or updating translations

All user-facing strings must go through the translation system — never hardcode English text directly in a component.

1. Add the string to both locale files:

  • src/locales/en.json — English (required)
  • src/locales/de.json — German (required)

Strings are organised into namespaces (JSON top-level keys) that mirror the feature area. Add new strings to the most relevant existing namespace, or create a new one if needed.

// en.json
{
"MyFeature": {
"my_new_string": "Hello world"
}
}

2. Use useTranslations in the component:

"use client";
import { useTranslations } from "next-intl";

const MyComponent = () => {
const t = useTranslations("MyFeature");
return <p>{t("my_new_string")}</p>;
};

For strings with dynamic values, use ICU message format:

"result_count": "{count} {category} found"
t("result_count", { count: 42, category: "Datasets" })

Using React Context

Two contexts are available globally:

ContextFileProvides
CategoryContextsrc/contexts/CategoryContext.tsxCategory list, counts, active category, loading/error state
FilterDialogContextsrc/contexts/FilterDialogContext.tsxFilter panel open/close state

Use useContext to consume them in client components. Do not create new global contexts for state that can be expressed through URL params — prefer URL params for anything search-related.

URL params as state

Search state (search text, active category, filters, pagination) lives in URL search params, not in component state. This keeps search state bookmarkable and shareable.

When a user changes a filter or page, update the URL params using useRouter and let the component re-render from the new params. Do not mirror URL param values into useState.


Adding a new entity type

If a new entity type needs to be added to the knowledge graph:

  1. Add the index name — Add the new type to the CATEGORIES constant in src/types/common.ts and src/app/api/utils/defaults.ts.

  2. Define the TypeScript type — Add a SchemaOrg[TypeName] interface in src/types/common.ts that extends SchemaOrgBase (or CreativeWorkBase if it is a creative work). Add it to the SchemaOrgResultItem union type.

  3. Add search fields — Add an entry to searchFieldsByCategory in src/app/api/utils/defaults.ts listing the OpenSearch fields to search for this type, with boost weights.

  4. Add filter fields — Add an entry to filterFieldsByCategory in src/app/api/utils/defaults.ts listing the fields that can be used as filters.

  5. Update Zod schemas — Add the new documentType literal to the union in src/types/api.ts.

  6. Update the OpenAPI spec — Add the new value to the DocumentType enum in src/app/api/openapi.yaml.

  7. Write tests — Add mock fixtures and test cases for the new type.


Adding a new filter field

To expose a new field as a filter for an existing entity type:

  1. Add the field name to the relevant entry in filterFieldsByCategory in src/app/api/utils/defaults.ts.
  2. If the field is a date field, add it to dateFields in the same file.
  3. If the field is a nested OpenSearch object, add it to nestedFieldsByCategory.
  4. Test the new filter via POST /api/search and POST /api/filters/search.

Merge request checklist

Before opening a merge request, confirm:

  • pnpm check passes with no errors (ESLint + TypeScript + Prettier)
  • pnpm test passes with no failures
  • New logic has test coverage
  • No hardcoded secrets or environment values in the code
  • If a new API endpoint was added, the OpenAPI spec (src/app/api/openapi.yaml) is updated
  • The MR targets dev, not main