Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ orbs:
executors:
cicd82:
docker:
- image: wunderio/silta-cicd:circleci-php8.3-node20-composer2-v1
- image: wunderio/silta-cicd:circleci-php8.3-node22-composer2-sqlite3.45-v1
Comment thread
vermario marked this conversation as resolved.
resource_class: small

commands:
Expand Down
2 changes: 1 addition & 1 deletion .ddev/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: next-drupal-starterkit
type: drupal
docroot: drupal/web
composer_root: drupal
nodejs_version: "20"
nodejs_version: "22"
php_version: "8.3"
webserver_type: nginx-fpm
xdebug_enabled: false
Expand Down
188 changes: 188 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# Copilot Instructions

## Architecture Overview

This is a **decoupled (headless) Drupal + Next.js** multilingual starter template by Wunder. The repository contains two separate applications:

- **`drupal/`** — Drupal 10.4 (PHP 8.3) backend: content management, GraphQL API, Elasticsearch indexing, OAuth authentication
- **`next/`** — Next.js 15 (App Router, TypeScript) frontend: consumes Drupal via GraphQL, handles routing, rendering, and user auth

Supporting services: MariaDB 10.11, Elasticsearch 8.9, Redis 4.7.

### Data Flow

1. Next.js issues GraphQL queries to `NEXT_PUBLIC_DRUPAL_BASE_URL/graphql` using Simple OAuth credentials
2. Pages are statically generated with on-demand ISR — Drupal POSTs to `/api/revalidate` when content is saved
3. Search queries are proxied through Drupal's `wunder_search` module to Elasticsearch
4. Frontend user auth uses Auth.js (next-auth v5 beta); users are created in Drupal via REST

### Custom Drupal Modules (`drupal/web/modules/custom/`)

| Module | Purpose |
|---|---|
| `wunder_next` | Configures the next-drupal integration and environment setup |
| `wunder_search` | Elasticsearch indexing and search proxy |
| `wunder_democontent` | Demo content migration |
| `wunder_sitemap` | XML sitemap generation |

---

## Local Development (DDEV)

**All npm commands must be run with `ddev` prefix and from the `next/` directory.**

```bash
# Initial setup (idempotent — safe to re-run; use -c flag for a clean slate)
./setup-ddev.sh
./setup-ddev.sh -c # drop everything and start fresh

# URLs after setup
# Backend: https://next-drupal-starterkit.ddev.site
# Frontend: https://frontend.ddev.site
```

> `localhost:3000` does not work in DDEV — use the DDEV URL above.

---

## Commands

### Next.js (`cd next` first)

```bash
ddev npm run dev # Dev server + GraphQL codegen in watch mode
ddev npm run dev:nocodegen # Dev server only
ddev npm run build # graphql-codegen + Next.js build
ddev npm run lint # ESLint check
ddev npm run lint:fix # ESLint auto-fix
ddev npm run graphql-codegen # Regenerate GraphQL types from Drupal schema
ddev npm run storybook # Storybook on port 6006
```

### Cypress E2E Tests (`cd next` first)

```bash
ddev npm run build && ddev npm run start # Required before running tests
ddev npm run cypress:run # Run all tests headlessly
ddev npm run cypress:run -- --spec "cypress/e2e/my.cy.ts" # Single spec
```

One-time Cypress setup inside DDEV:
```bash
ddev npx cypress install
ddev exec ./install-cypress-deps.sh
```

### Drupal

```bash
ddev drush cr # Clear caches
ddev drush uli # One-time login link
ddev drush mim --group=demo_content # Import demo content
ddev drush eshd -y && ddev drush eshs # Reset and reindex Elasticsearch
ddev composer install # Install PHP dependencies
```

### PHP Code Quality (`cd drupal` first)

```bash
ddev grumphp run # All checks: phpcs, phpstan, yaml/json lint, phpunit
ddev npm run lint # ESLint + Stylelint for custom modules/themes
ddev phpunit web/modules/custom/wunder_next/tests/ # Single module tests
```

PHP linting targets only `web/modules/custom/` and `web/themes/custom/` (configured in `grumphp.yml`). Standard: `WunderDrupal` + `WunderSecurity` (see `phpcs.xml`). PHPStan level 5 (see `phpstan.neon`).

---

## Key Conventions

### Environment Variables

All Next.js env vars are declared in `next/env.ts` using `@t3-oss/env-nextjs` with Zod validation. **When adding a new env var**, you must register it in four places:

1. `.ddev/config.yaml` (local dev)
2. `next/env.ts` — twice: in the `server`/`client` schema and in `runtimeEnv`
3. `.circleci/config.yml`
4. `silta/silta-next.yml`

Import as: `import { env } from "@/env";` — never use `process.env` directly in app code.

### GraphQL & TypeScript Types

GraphQL queries live in `next/src/lib/graphql/` and `next/src/lib/gql/` (generated). **Never edit files in `src/lib/gql/`** — they are gitignored and regenerated by `graphql-codegen`.

When you add or modify a query/fragment, the types regenerate automatically on `dev` and `build`. If the **Drupal schema itself changes**, stop and restart `dev` (or re-run `graphql-codegen`) to re-fetch the schema, then also run `ddev drush cr`.

### TypeScript

TypeScript is intentionally loose (`strict: false`, `strictNullChecks: false`) to lower the barrier for entry. The `@/*` alias resolves to `next/src/*`.

### Multilingual

The frontend supports three locales: `en` (default), `fi`, `sv`. All routes are nested under the `[locale]` segment in `next/src/app/[locale]/`. Translations are managed via `next-intl`. Middleware handles locale detection and route protection.

### Tailwind & Components

Styling uses Tailwind CSS 3 with shadcn/ui (Radix UI primitives + `class-variance-authority`). Theme tokens (colors, radius) are CSS custom properties defined via Tailwind's config (`next/tailwind.config.js`). Component config is at `next/components.json`.

### Silta Deployment

`silta/` contains Helm/Kubernetes values for Wunder's [Silta](https://wunderio.github.io/silta/) hosting. CircleCI handles CI/CD (`circleci/config.yml`). There are no GitHub Actions workflows — all CI runs on CircleCI.

### Drupal Recipes

Drupal setup is automated via a recipe in `drupal/recipes/`. Running `./setup-ddev.sh` applies it automatically. When modifying Drupal config, export with `ddev drush cex` — config is tracked in `drupal/config/`.

### Redis Caching

The cache handler package is `@fortedigital/nextjs-cache-handler` (`next/cache-handler.mjs`). It replaced `@neshca/cache-handler` during the Next.js 15 upgrade. It is **only active when `NODE_ENV=production`** — in development, no custom cache handler is used.

**Behaviour:**
- Cache keys are prefixed with `cache-{buildId}:` so deploys don't serve stale entries from a previous build
- If Redis is unavailable or the connection times out (3 s), it **falls back silently to an in-memory LRU** handler — the app keeps working

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This documentation says the Redis connection times out after 3s and falls back to LRU, but next/cache-handler.mjs currently calls client.connect() without a timeout and can block startup. Either implement the connect timeout/fallback behavior in code or adjust this bullet to match the actual behavior.

Suggested change
- If Redis is unavailable or the connection times out (3 s), it **falls back silently to an in-memory LRU** handler — the app keeps working
- Redis connectivity (including timeouts and any fallbacks) is handled by `@fortedigital/nextjs-cache-handler` / `@redis/client`; there is currently **no custom 3 s connect-timeout or guaranteed automatic fallback to an in-memory LRU** implemented in `next/cache-handler.mjs`

Copilot uses AI. Check for mistakes.
- `cacheMaxMemorySize: 0` in `next.config.mjs` disables Next.js's own in-memory cache entirely when the custom handler is active
Comment thread
vermario marked this conversation as resolved.
- **Flush Redis cache** when deploying between Next.js major versions — cache formats are incompatible

**Required env vars** (both must be set for Redis to activate; uses `@redis/client` directly):
```
REDIS_HOST=<hostname>
REDIS_PASS=<password>
```

**Cache TTL constants** (`next/src/lib/constants.ts`):
```ts
REVALIDATE_SHORT = 10 // 10 seconds — for frequently changing data
REVALIDATE_LONG = 600 // 10 minutes — must match defaultStaleAge in cache-handler.mjs
```
> If you change `REVALIDATE_LONG`, update `defaultStaleAge` in `cache-handler.mjs` to match.

**Local (DDEV):** Redis runs by default. Inspect the cache with:
```bash
ddev redis # Opens a redis-cli session
```

### Async params / draftMode (Next.js 15)

In Next.js 15, `params`, `searchParams`, `cookies()`, `headers()`, and `draftMode()` are all **Promises** in Server Components and must be awaited:

```tsx
// Page / layout (Server Component)
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const { isEnabled } = await draftMode();
}

// Client Component — use React.use() to unwrap
"use client";
import { use } from "react";
export default function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = use(params);
}
```

This applies to `generateMetadata` and `generateStaticParams` too.

### Auth

The Drupal backend uses **Simple OAuth** (two consumers: one for the server-to-server Next.js integration, one for the viewer/public role). OAuth key pairs are generated fresh for each environment. On the frontend, **Auth.js** (NextAuth v5 beta) handles user sessions; protected routes are defined in `next/src/middleware.ts`.
3 changes: 2 additions & 1 deletion next/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,5 @@ lerna-debug.log*

# Cypress gitignore
/cypress/screenshots/
/cypress/videos/
/cypress/videos/
/tsconfig.tsbuildinfo
14 changes: 6 additions & 8 deletions next/cache-handler.mjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
// @ts-check

import { CacheHandler } from "@neshca/cache-handler";
import createLruHandler from "@neshca/cache-handler/local-lru";
import createRedisHandler from "@neshca/cache-handler/redis-strings";
import { createClient } from "redis";
import { CacheHandler } from "@fortedigital/nextjs-cache-handler";
import createLruHandler from "@fortedigital/nextjs-cache-handler/local-lru";
import createRedisHandler from "@fortedigital/nextjs-cache-handler/redis-strings";
import { createClient } from "@redis/client";

// This should match the REVALIDATE_LONG value in src/lib/constants.ts
const defaultStaleAge = 60 * 10;

CacheHandler.onCreation(async ({ buildId }) => {
/** @type {import("redis").RedisClientType | undefined} */
let client;
/** @type {import("@neshca/cache-handler").Handler | undefined} */
let handler;
/** @type {any} */
let handler = createLruHandler();

Comment on lines 11 to 15

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CacheHandler.onCreation can return handlers: [undefined] when Redis env vars are missing or during the build phase, because handler is only assigned inside the if (...) block. Initialize handler to createLruHandler() by default (and only swap to Redis when ready), or ensure the returned handlers array contains only defined handlers.

Copilot uses AI. Check for mistakes.
if (
// Do not create the Redis handler during the build phase.
Expand Down Expand Up @@ -68,7 +67,6 @@ CacheHandler.onCreation(async ({ buildId }) => {
}

if (client?.isReady) {
/** @type {import("@neshca/cache-handler/redis-strings").CreateRedisStringsHandlerOptions} */
const redisHandlerOptions = {
client,
keyPrefix: `cache-${buildId}:`,
Comment on lines 69 to 72

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timeoutMs here only applies to Redis operations after the client is connected; client.connect() earlier can still block startup if Redis is unreachable. If you expect a 3s failover to LRU, add an explicit connect timeout (e.g., Redis socket options or racing connect() against a timer) and fall back when it elapses.

Copilot uses AI. Check for mistakes.
Expand Down
3 changes: 2 additions & 1 deletion next/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
5 changes: 1 addition & 4 deletions next/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,7 @@ const nextConfig = {
}),
},

experimental: {
instrumentationHook: true,
swrDelta: 31536000, // 1 year
},
expireTime: 31536000, // 1 year (formerly experimental.swrDelta)

async generateBuildId() {
return process.env.CIRCLECI
Expand Down
Loading