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