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
| Branch | Purpose |
|---|---|
main | Stable, production-ready code |
dev | Active development — all feature branches merge here |
feature/* | New features (e.g. feature/add-filter-chips) |
bugfix/* | Bug fixes (e.g. bugfix/reference-link) |
Workflow:
- Branch off
dev - Make your changes
- Open a merge request targeting
dev devis merged intomainfor 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)
| Setting | Value |
|---|---|
| Print width | 100 characters |
| Tab width | 2 spaces |
| Trailing commas | ES5 (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
- Create a file
src/app/api/__tests__/api/[endpoint].test.ts - Mock the OpenSearch client:
jest.mock("@api/utils/client") - Add mock OpenSearch responses to
src/app/api/utils/__mocks__/mockResults/[endpoint]/ - Add expected API response fixtures to
src/app/api/__tests__/expectedResults/[endpoint]/ - Use
createMockNextRequestWithParamsfrom__mocks__/testServer.tsto 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
| Type | Location |
|---|---|
| Feature-specific UI | src/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:
| Token | Usage |
|---|---|
primary-helmholtz-dunkelblau | Primary dark blue (text, borders) |
primary-helmholtz-hellblau | Light blue (hover states) |
primary-helmholtz-weiss | White backgrounds |
secondary-helmholtz-mint | Mint green (CTAs, buttons) |
secondary-helmholtz-highlightblau | Highlight 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:
- Create a folder matching the URL path, e.g.
src/app/[locale]/my-page/ - Add a
page.tsxfile inside it - If the page needs a loading or error state, add
loading.tsxorerror.tsxalongside 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:
| Context | File | Provides |
|---|---|---|
CategoryContext | src/contexts/CategoryContext.tsx | Category list, counts, active category, loading/error state |
FilterDialogContext | src/contexts/FilterDialogContext.tsx | Filter 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:
-
Add the index name — Add the new type to the
CATEGORIESconstant insrc/types/common.tsandsrc/app/api/utils/defaults.ts. -
Define the TypeScript type — Add a
SchemaOrg[TypeName]interface insrc/types/common.tsthat extendsSchemaOrgBase(orCreativeWorkBaseif it is a creative work). Add it to theSchemaOrgResultItemunion type. -
Add search fields — Add an entry to
searchFieldsByCategoryinsrc/app/api/utils/defaults.tslisting the OpenSearch fields to search for this type, with boost weights. -
Add filter fields — Add an entry to
filterFieldsByCategoryinsrc/app/api/utils/defaults.tslisting the fields that can be used as filters. -
Update Zod schemas — Add the new
documentTypeliteral to the union insrc/types/api.ts. -
Update the OpenAPI spec — Add the new value to the
DocumentTypeenum insrc/app/api/openapi.yaml. -
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:
- Add the field name to the relevant entry in
filterFieldsByCategoryinsrc/app/api/utils/defaults.ts. - If the field is a date field, add it to
dateFieldsin the same file. - If the field is a nested OpenSearch object, add it to
nestedFieldsByCategory. - Test the new filter via
POST /api/searchandPOST /api/filters/search.
Merge request checklist
Before opening a merge request, confirm:
-
pnpm checkpasses with no errors (ESLint + TypeScript + Prettier) -
pnpm testpasses 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, notmain
