init
This commit is contained in:
8
.changeset/README.md
Normal file
8
.changeset/README.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Changesets
|
||||||
|
|
||||||
|
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||||
|
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||||
|
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||||
|
|
||||||
|
We have a quick list of common questions to get you started engaging with this project in
|
||||||
|
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||||
11
.changeset/config.json
Normal file
11
.changeset/config.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
|
||||||
|
"changelog": "@changesets/cli/changelog",
|
||||||
|
"commit": false,
|
||||||
|
"fixed": [],
|
||||||
|
"linked": [],
|
||||||
|
"access": "restricted",
|
||||||
|
"baseBranch": "main",
|
||||||
|
"updateInternalDependencies": "patch",
|
||||||
|
"ignore": []
|
||||||
|
}
|
||||||
36
.gitea/workflows/ci.yml
Normal file
36
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: Code Quality
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-and-build:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node_version: 20
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
38
.gitea/workflows/release.yml
Normal file
38
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: Release Packages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node_version: 20
|
||||||
|
cache: 'pnpm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create Release Pull Request or Publish
|
||||||
|
id: changesets
|
||||||
|
run: pnpm release
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Changesets
|
||||||
|
.changeset/*.lock
|
||||||
3
.npmrc
Normal file
3
.npmrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@mintel:registry=https://npm.infra.mintel.me
|
||||||
|
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
||||||
|
always-auth=true
|
||||||
77
README.md
Normal file
77
README.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Mintel Monorepo
|
||||||
|
|
||||||
|
This monorepo manages multiple client websites using a shared technology stack: Next.js, TypeScript, and React.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `apps/`: Client websites (e.g., `sample-website`).
|
||||||
|
- `packages/`: Shared packages under the `@mintel` namespace.
|
||||||
|
- `@mintel/tsconfig`: Shared TypeScript configurations.
|
||||||
|
- `@mintel/eslint-config`: Shared ESLint configurations.
|
||||||
|
- `@mintel/next-config`: Shared Next.js configuration wrapper.
|
||||||
|
- `@mintel/next-utils`: Reusable logic (i18n, rate limiting, etc.).
|
||||||
|
- `@mintel/infra`: Infrastructure templates (Docker, Gitea Actions).
|
||||||
|
- `@mintel/cli`: CLI tool for project setup and migration.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [pnpm](https://pnpm.io/) (v10+)
|
||||||
|
- Node.js (v20+)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a New Project
|
||||||
|
|
||||||
|
Use the Mintel CLI to set up a new project or migrate an existing one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mintel/cli start init apps/my-new-website
|
||||||
|
```
|
||||||
|
|
||||||
|
## Versioning and Releasing
|
||||||
|
|
||||||
|
We use [Changesets](https://github.com/changesets/changesets) for version management.
|
||||||
|
|
||||||
|
### 1. Add a changeset
|
||||||
|
When you make a change that requires a version bump, run:
|
||||||
|
```bash
|
||||||
|
pnpm changeset
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Version packages
|
||||||
|
To bump versions based on accumulated changesets:
|
||||||
|
```bash
|
||||||
|
pnpm version-packages
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Publish to registry
|
||||||
|
To build and publish all changed packages to the private registry:
|
||||||
|
```bash
|
||||||
|
pnpm release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Projects are hosted on Hetzner with Docker and Traefik, deployed via Gitea Actions. See `@mintel/infra` for templates.
|
||||||
|
|
||||||
|
## Registry
|
||||||
|
|
||||||
|
Private npm registry: [https://npm.infra.mintel.me](https://npm.infra.mintel.me)
|
||||||
3
apps/sample-website/eslint.config.mjs
Normal file
3
apps/sample-website/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { nextConfig } from "@mintel/eslint-config/next";
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6
apps/sample-website/next.config.ts
Normal file
6
apps/sample-website/next.config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import mintelNextConfig from "@mintel/next-config";
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
export default mintelNextConfig(nextConfig);
|
||||||
27
apps/sample-website/package.json
Normal file
27
apps/sample-website/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "sample-website",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "15.1.6",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"@mintel/next-utils": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"@mintel/tsconfig": "workspace:*",
|
||||||
|
"@mintel/eslint-config": "workspace:*",
|
||||||
|
"@mintel/next-config": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/sample-website/src/app/globals.css
Normal file
14
apps/sample-website/src/app/globals.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--foreground);
|
||||||
|
background: var(--background);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
19
apps/sample-website/src/app/layout.tsx
Normal file
19
apps/sample-website/src/app/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Sample Website",
|
||||||
|
description: "A sample website using @mintel shared packages",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
apps/sample-website/src/app/page.tsx
Normal file
36
apps/sample-website/src/app/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { isValidLang, rateLimit } from "@mintel/next-utils";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const testLang = "en";
|
||||||
|
const isLangValid = isValidLang(testLang);
|
||||||
|
|
||||||
|
const handleTestRateLimit = async () => {
|
||||||
|
try {
|
||||||
|
await rateLimit("test-user");
|
||||||
|
console.log("Rate limit check passed");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen flex-col items-center justify-between p-24">
|
||||||
|
<div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex">
|
||||||
|
<h1 className="text-4xl font-bold">Sample Website</h1>
|
||||||
|
<p className="mt-4">
|
||||||
|
Testing @mintel/next-utils:
|
||||||
|
<br />
|
||||||
|
Is {'"'}en{'"'} valid? {isLangValid ? "Yes" : "No"}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleTestRateLimit}
|
||||||
|
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
|
||||||
|
>
|
||||||
|
Test Rate Limit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/sample-website/tsconfig.json
Normal file
10
apps/sample-website/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "@mintel/tsconfig/nextjs.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/monorepo",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "pnpm --filter \"./packages/*\" build",
|
||||||
|
"dev": "pnpm --filter \"./packages/*\" dev",
|
||||||
|
"lint": "pnpm --filter \"./packages/*\" lint",
|
||||||
|
"test": "pnpm --filter \"./packages/*\" test",
|
||||||
|
"changeset": "changeset",
|
||||||
|
"version-packages": "changeset version",
|
||||||
|
"release": "pnpm build && changeset publish"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@changesets/cli": "^2.29.8",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
packages/cli/README.md
Normal file
26
packages/cli/README.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# @mintel/cli
|
||||||
|
|
||||||
|
CLI tool for managing the Mintel monorepo and scaffolding new client websites.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `init <path>`
|
||||||
|
|
||||||
|
Initializes a new website project in the specified path (relative to the monorepo root).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @mintel/cli start init apps/my-new-website
|
||||||
|
```
|
||||||
|
|
||||||
|
This command will:
|
||||||
|
1. Create the project directory.
|
||||||
|
2. Generate `package.json`, `tsconfig.json`, and `eslint.config.mjs` extending `@mintel` defaults.
|
||||||
|
3. Set up a localized Next.js structure (`src/app/[locale]`).
|
||||||
|
4. Configure `next-intl` middleware and request config.
|
||||||
|
5. Inject production-ready `Dockerfile`, `docker-compose.yml`, and Gitea Actions deployment workflows.
|
||||||
30
packages/cli/package.json
Normal file
30
packages/cli/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/cli",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"mintel": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup src/index.ts --format esm --target es2020",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"dev": "tsup src/index.ts --format esm --watch --target es2020"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^11.0.0",
|
||||||
|
"fs-extra": "^11.1.0",
|
||||||
|
"chalk": "^5.3.0",
|
||||||
|
"prompts": "^2.4.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsup": "^8.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"@types/fs-extra": "^11.0.0",
|
||||||
|
"@types/prompts": "^2.4.4",
|
||||||
|
"@mintel/tsconfig": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
228
packages/cli/src/index.ts
Normal file
228
packages/cli/src/index.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { Command } from "commander";
|
||||||
|
import fs from "fs-extra";
|
||||||
|
import path from "path";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program
|
||||||
|
.name("mintel")
|
||||||
|
.description("CLI for Mintel monorepo management")
|
||||||
|
.version("1.0.0");
|
||||||
|
|
||||||
|
program
|
||||||
|
.command("init <path>")
|
||||||
|
.description("Initialize a new website project")
|
||||||
|
.action(async (projectPath) => {
|
||||||
|
const fullPath = path.isAbsolute(projectPath)
|
||||||
|
? projectPath
|
||||||
|
: path.resolve(process.cwd(), "../../", projectPath);
|
||||||
|
const projectName = path.basename(fullPath);
|
||||||
|
|
||||||
|
console.log(chalk.blue(`Initializing new project: ${projectName}...`));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create directory
|
||||||
|
await fs.ensureDir(fullPath);
|
||||||
|
|
||||||
|
// Create package.json
|
||||||
|
const pkgJson = {
|
||||||
|
name: projectName,
|
||||||
|
version: "0.1.0",
|
||||||
|
private: true,
|
||||||
|
type: "module",
|
||||||
|
scripts: {
|
||||||
|
dev: "next dev",
|
||||||
|
build: "next build",
|
||||||
|
start: "next start",
|
||||||
|
lint: "next lint",
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
next: "15.1.6",
|
||||||
|
react: "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"@mintel/next-utils": "workspace:*",
|
||||||
|
},
|
||||||
|
devDependencies: {
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
typescript: "^5.0.0",
|
||||||
|
"@mintel/tsconfig": "workspace:*",
|
||||||
|
"@mintel/eslint-config": "workspace:*",
|
||||||
|
"@mintel/next-config": "workspace:*",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeJson(path.join(fullPath, "package.json"), pkgJson, {
|
||||||
|
spaces: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create next.config.ts
|
||||||
|
const nextConfig = `import mintelNextConfig from "@mintel/next-config";
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
export default mintelNextConfig(nextConfig);
|
||||||
|
`;
|
||||||
|
await fs.writeFile(path.join(fullPath, "next.config.ts"), nextConfig);
|
||||||
|
|
||||||
|
// Create tsconfig.json
|
||||||
|
const tsConfig = {
|
||||||
|
extends: "@mintel/tsconfig/nextjs.json",
|
||||||
|
compilerOptions: {
|
||||||
|
paths: {
|
||||||
|
"@/*": ["./src/*"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
],
|
||||||
|
exclude: ["node_modules"],
|
||||||
|
};
|
||||||
|
await fs.writeJson(path.join(fullPath, "tsconfig.json"), tsConfig, {
|
||||||
|
spaces: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create eslint.config.mjs
|
||||||
|
const eslintConfig = `import { nextConfig } from "@mintel/eslint-config/next";
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
|
`;
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fullPath, "eslint.config.mjs"),
|
||||||
|
eslintConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create basic src structure
|
||||||
|
await fs.ensureDir(path.join(fullPath, "src/app/[locale]"));
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fullPath, "src/middleware.ts"),
|
||||||
|
`import { createMintelMiddleware } from "@mintel/next-utils";
|
||||||
|
|
||||||
|
export default createMintelMiddleware({
|
||||||
|
locales: ["en", "de"],
|
||||||
|
defaultLocale: "en",
|
||||||
|
logRequests: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!api|_next|_vercel|health|.*\\\\..*).*)", "/", "/(de|en)/:path*"]
|
||||||
|
};
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create i18n/request.ts
|
||||||
|
await fs.ensureDir(path.join(fullPath, "src/i18n"));
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fullPath, "src/i18n/request.ts"),
|
||||||
|
`import { createMintelI18nRequestConfig } from "@mintel/next-utils";
|
||||||
|
|
||||||
|
export default createMintelI18nRequestConfig(
|
||||||
|
["en", "de"],
|
||||||
|
"en",
|
||||||
|
(locale) => import(\`../../messages/\${locale}.json\`)
|
||||||
|
);
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create messages directory
|
||||||
|
await fs.ensureDir(path.join(fullPath, "messages"));
|
||||||
|
await fs.writeJson(path.join(fullPath, "messages/en.json"), {
|
||||||
|
Index: {
|
||||||
|
title: "Welcome"
|
||||||
|
}
|
||||||
|
}, { spaces: 2 });
|
||||||
|
await fs.writeJson(path.join(fullPath, "messages/de.json"), {
|
||||||
|
Index: {
|
||||||
|
title: "Willkommen"
|
||||||
|
}
|
||||||
|
}, { spaces: 2 });
|
||||||
|
|
||||||
|
// Create instrumentation.ts
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fullPath, "src/instrumentation.ts"),
|
||||||
|
`import * as Sentry from '@sentry/nextjs';
|
||||||
|
|
||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
|
// Server-side initialization
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onRequestError = Sentry.captureRequestError;
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fullPath, "src/app/[locale]/layout.tsx"),
|
||||||
|
`import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "${projectName}",
|
||||||
|
description: "Created with Mintel CLI",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
params: { locale }
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: { locale: string };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang={locale}>
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(fullPath, "src/app/[locale]/page.tsx"),
|
||||||
|
`export default function Home() {
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>Welcome to ${projectName}</h1>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Copy infra templates
|
||||||
|
const infraPath = path.resolve(__dirname, "../../infra");
|
||||||
|
if (await fs.pathExists(infraPath)) {
|
||||||
|
await fs.copy(
|
||||||
|
path.join(infraPath, "docker/Dockerfile.nextjs"),
|
||||||
|
path.join(fullPath, "Dockerfile")
|
||||||
|
);
|
||||||
|
await fs.copy(
|
||||||
|
path.join(infraPath, "docker/docker-compose.template.yml"),
|
||||||
|
path.join(fullPath, "docker-compose.yml")
|
||||||
|
);
|
||||||
|
await fs.ensureDir(path.join(fullPath, ".gitea/workflows"));
|
||||||
|
await fs.copy(
|
||||||
|
path.join(infraPath, "gitea/deploy-action.yml"),
|
||||||
|
path.join(fullPath, ".gitea/workflows/deploy.yml")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
chalk.green(`Successfully initialized ${projectName} at ${fullPath}`)
|
||||||
|
);
|
||||||
|
console.log(chalk.yellow("\nNext steps:"));
|
||||||
|
console.log(chalk.cyan("1. pnpm install"));
|
||||||
|
console.log(chalk.cyan(`2. cd ${projectPath} && pnpm dev`));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(chalk.red("Error initializing project:"), error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
program.parse();
|
||||||
11
packages/cli/tsconfig.json
Normal file
11
packages/cli/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "@mintel/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["esnext"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
13
packages/eslint-config/README.md
Normal file
13
packages/eslint-config/README.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# @mintel/eslint-config
|
||||||
|
|
||||||
|
Shared ESLint configurations for Mintel projects.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Next.js
|
||||||
|
In your `eslint.config.mjs`:
|
||||||
|
```javascript
|
||||||
|
import { nextConfig } from "@mintel/eslint-config/next";
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
|
```
|
||||||
24
packages/eslint-config/next.js
Normal file
24
packages/eslint-config/next.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const nextConfig = [
|
||||||
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||||
|
"@typescript-eslint/no-require-imports": "off",
|
||||||
|
"prefer-const": "warn",
|
||||||
|
"react/no-unescaped-entities": "off",
|
||||||
|
"@next/next/no-img-element": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
18
packages/eslint-config/package.json
Normal file
18
packages/eslint-config/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/eslint-config",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.js",
|
||||||
|
"./next": "./next.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@eslint/eslintrc": "^3.0.0",
|
||||||
|
"eslint-config-next": "15.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/infra/README.md
Normal file
12
packages/infra/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# @mintel/infra
|
||||||
|
|
||||||
|
Infrastructure templates for Mintel projects.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- `docker/`: Universal `Dockerfile.nextjs` and `docker-compose.template.yml`.
|
||||||
|
- `gitea/`: Production-ready `deploy-action.yml` for Gitea Actions.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
These files are automatically injected into new projects by the `@mintel/cli`.
|
||||||
66
packages/infra/docker/Dockerfile.nextjs
Normal file
66
packages/infra/docker/Dockerfile.nextjs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
FROM node:20-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat curl
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install dependencies based on the preferred package manager
|
||||||
|
COPY package.json package-lock.json* pnpm-lock.yaml* ./
|
||||||
|
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||||
|
elif [ -f package-lock.json ]; then npm ci; \
|
||||||
|
else npm i; fi
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Next.js collects completely anonymous telemetry data about general usage.
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# Build-time environment variables for Next.js
|
||||||
|
ARG NEXT_PUBLIC_BASE_URL
|
||||||
|
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||||
|
|
||||||
|
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
|
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||||
|
else npm run build; fi
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl for health checks
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
# Set the correct permission for prerender cache
|
||||||
|
RUN mkdir .next
|
||||||
|
RUN chown nextjs:nodejs .next
|
||||||
|
|
||||||
|
# Automatically leverage output traces to reduce image size
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
33
packages/infra/docker/docker-compose.template.yml
Normal file
33
packages/infra/docker/docker-compose.template.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: registry.infra.mintel.me/${PROJECT_NAME:-mintel/app}:${IMAGE_TAG:-latest}
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- infra
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
env_file:
|
||||||
|
- ${ENV_FILE:-.env}
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
# HTTP ⇒ HTTPS redirect
|
||||||
|
- "traefik.http.routers.${APP_NAME:-app}-web.rule=Host(${TRAEFIK_HOST}) && !PathPrefix(`/.well-known/acme-challenge/`)"
|
||||||
|
- "traefik.http.routers.${APP_NAME:-app}-web.entrypoints=web"
|
||||||
|
- "traefik.http.routers.${APP_NAME:-app}-web.middlewares=redirect-https"
|
||||||
|
# HTTPS router
|
||||||
|
- "traefik.http.routers.${APP_NAME:-app}.rule=Host(${TRAEFIK_HOST})"
|
||||||
|
- "traefik.http.routers.${APP_NAME:-app}.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.${APP_NAME:-app}.tls.certresolver=le"
|
||||||
|
- "traefik.http.routers.${APP_NAME:-app}.tls=true"
|
||||||
|
- "traefik.http.routers.${APP_NAME:-app}.service=${APP_NAME:-app}"
|
||||||
|
- "traefik.http.services.${APP_NAME:-app}.loadbalancer.server.port=3000"
|
||||||
|
- "traefik.http.services.${APP_NAME:-app}.loadbalancer.server.scheme=http"
|
||||||
|
# Forwarded Headers
|
||||||
|
- "traefik.http.middlewares.${APP_NAME:-app}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||||
|
- "traefik.http.middlewares.${APP_NAME:-app}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||||
|
# Middlewares
|
||||||
|
- "traefik.http.routers.${APP_NAME:-app}.middlewares=${APP_NAME:-app}-forward,compress"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
infra:
|
||||||
|
external: true
|
||||||
197
packages/infra/gitea/deploy-action.yml
Normal file
197
packages/infra/gitea/deploy-action.yml
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
name: Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, staging]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: docker
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# LOGGING: Workflow Start - Full Transparency
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
- name: 📋 Log Workflow Start
|
||||||
|
run: |
|
||||||
|
echo "🚀 Starting deployment for ${{ github.repository }} (${{ github.ref }})"
|
||||||
|
echo " • Branch: ${{ github.ref_name }}"
|
||||||
|
echo " • Commit: ${{ github.sha }}"
|
||||||
|
echo " • Timestamp: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# LOGGING: Registry Login Phase
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
- name: 🔐 Login to private registry
|
||||||
|
run: |
|
||||||
|
echo "🔐 Authenticating with registry.infra.mintel.me..."
|
||||||
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# LOGGING: Build Phase
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
- name: 🏗️ Build Docker image
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_BASE_URL || (secrets.STAGING_NEXT_PUBLIC_BASE_URL || secrets.NEXT_PUBLIC_BASE_URL) }}
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
||||||
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
||||||
|
run: |
|
||||||
|
echo "🏗️ Building Docker image (linux/arm64) for branch ${{ github.ref_name }}..."
|
||||||
|
docker buildx build \
|
||||||
|
--pull \
|
||||||
|
--platform linux/arm64 \
|
||||||
|
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
|
||||||
|
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="$NEXT_PUBLIC_UMAMI_WEBSITE_ID" \
|
||||||
|
--build-arg NEXT_PUBLIC_UMAMI_SCRIPT_URL="$NEXT_PUBLIC_UMAMI_SCRIPT_URL" \
|
||||||
|
-t registry.infra.mintel.me/${{ github.repository }}:${{ github.sha }} \
|
||||||
|
-t registry.infra.mintel.me/${{ github.repository }}:latest \
|
||||||
|
--push .
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# LOGGING: Deployment Phase
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
- name: 🚀 Deploy to server
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_BASE_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_BASE_URL || (secrets.STAGING_NEXT_PUBLIC_BASE_URL || secrets.NEXT_PUBLIC_BASE_URL) }}
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || (secrets.STAGING_NEXT_PUBLIC_UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID) }}
|
||||||
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${{ github.ref_name == 'main' && secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || (secrets.STAGING_NEXT_PUBLIC_UMAMI_SCRIPT_URL || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL) }}
|
||||||
|
SENTRY_DSN: ${{ github.ref_name == 'main' && secrets.SENTRY_DSN || (secrets.STAGING_SENTRY_DSN || secrets.SENTRY_DSN) }}
|
||||||
|
MAIL_HOST: ${{ github.ref_name == 'main' && secrets.MAIL_HOST || (secrets.STAGING_MAIL_HOST || secrets.MAIL_HOST) }}
|
||||||
|
MAIL_PORT: ${{ github.ref_name == 'main' && secrets.MAIL_PORT || (secrets.STAGING_MAIL_PORT || secrets.MAIL_PORT) }}
|
||||||
|
MAIL_USERNAME: ${{ github.ref_name == 'main' && secrets.MAIL_USERNAME || (secrets.STAGING_MAIL_USERNAME || secrets.MAIL_USERNAME) }}
|
||||||
|
MAIL_PASSWORD: ${{ github.ref_name == 'main' && secrets.MAIL_PASSWORD || (secrets.STAGING_MAIL_PASSWORD || secrets.MAIL_PASSWORD) }}
|
||||||
|
MAIL_FROM: ${{ github.ref_name == 'main' && secrets.MAIL_FROM || (secrets.STAGING_MAIL_FROM || secrets.MAIL_FROM) }}
|
||||||
|
MAIL_RECIPIENTS: ${{ github.ref_name == 'main' && secrets.MAIL_RECIPIENTS || (secrets.STAGING_MAIL_RECIPIENTS || secrets.MAIL_RECIPIENTS) }}
|
||||||
|
run: |
|
||||||
|
BRANCH=${{ github.ref_name }}
|
||||||
|
|
||||||
|
# Derive domain from NEXT_PUBLIC_BASE_URL (strip https:// and trailing slash)
|
||||||
|
DOMAIN=$(echo "$NEXT_PUBLIC_BASE_URL" | sed -E 's|https?://||' | sed -E 's|/.*||')
|
||||||
|
|
||||||
|
if [ "$BRANCH" = "main" ]; then
|
||||||
|
ENV_FILE=.env.prod
|
||||||
|
# For production, we want both root and www
|
||||||
|
TRAEFIK_HOST="\`$DOMAIN\`, \`www.$DOMAIN\`"
|
||||||
|
else
|
||||||
|
ENV_FILE=.env.staging
|
||||||
|
TRAEFIK_HOST="\`$DOMAIN\`"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 Deploying branch $BRANCH to $ENV_FILE..."
|
||||||
|
echo "🌐 Domain: $DOMAIN"
|
||||||
|
|
||||||
|
# Setup SSH
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
# Create .env file content
|
||||||
|
cat > /tmp/app.env << EOF
|
||||||
|
# ============================================================================
|
||||||
|
# Environment Configuration ($BRANCH)
|
||||||
|
# ============================================================================
|
||||||
|
# Auto-generated by CI/CD workflow
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
NODE_ENV=production
|
||||||
|
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
|
||||||
|
SENTRY_DSN=$SENTRY_DSN
|
||||||
|
MAIL_HOST=$MAIL_HOST
|
||||||
|
MAIL_PORT=$MAIL_PORT
|
||||||
|
MAIL_USERNAME=$MAIL_USERNAME
|
||||||
|
MAIL_PASSWORD=$MAIL_PASSWORD
|
||||||
|
MAIL_FROM=$MAIL_FROM
|
||||||
|
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
||||||
|
|
||||||
|
# Deployment variables for docker-compose
|
||||||
|
IMAGE_TAG=${{ github.sha }}
|
||||||
|
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||||
|
ENV_FILE=$ENV_FILE
|
||||||
|
EOF
|
||||||
|
|
||||||
|
APP_DIR="/home/deploy/sites/${{ github.event.repository.name }}"
|
||||||
|
ssh -o StrictHostKeyChecking=accept-new root@${{ secrets.SSH_HOST }} "mkdir -p $APP_DIR"
|
||||||
|
scp -o StrictHostKeyChecking=accept-new /tmp/app.env root@${{ secrets.SSH_HOST }}:$APP_DIR/$ENV_FILE
|
||||||
|
scp -o StrictHostKeyChecking=accept-new docker-compose.yml root@${{ secrets.SSH_HOST }}:$APP_DIR/docker-compose.yml
|
||||||
|
|
||||||
|
ssh -o StrictHostKeyChecking=accept-new root@${{ secrets.SSH_HOST }} bash << EOF
|
||||||
|
set -e
|
||||||
|
cd $APP_DIR
|
||||||
|
|
||||||
|
chmod 600 $ENV_FILE
|
||||||
|
chown deploy:deploy $ENV_FILE
|
||||||
|
|
||||||
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
echo "📥 Pulling images..."
|
||||||
|
IMAGE_TAG=${{ github.sha }} ENV_FILE=$ENV_FILE TRAEFIK_HOST="$TRAEFIK_HOST" docker compose --env-file $ENV_FILE pull
|
||||||
|
|
||||||
|
echo "🚀 Starting containers..."
|
||||||
|
IMAGE_TAG=${{ github.sha }} ENV_FILE=$ENV_FILE TRAEFIK_HOST="$TRAEFIK_HOST" docker compose --env-file $ENV_FILE up -d
|
||||||
|
|
||||||
|
echo "🧹 Cleaning up old images..."
|
||||||
|
docker system prune -f
|
||||||
|
|
||||||
|
echo "⏳ Giving the app a few seconds to warm up..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
echo "🔍 Checking container status..."
|
||||||
|
docker compose --env-file $ENV_FILE ps
|
||||||
|
|
||||||
|
if ! docker compose --env-file $ENV_FILE ps | grep -q "Up"; then
|
||||||
|
echo "❌ Container failed to start"
|
||||||
|
docker compose --env-file $ENV_FILE logs --tail=100
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Deployment complete!"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
rm -f /tmp/app.env
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# LOGGING: Workflow Summary
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
- name: 📊 Workflow Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "📊 Status: ${{ job.status }}"
|
||||||
|
echo "🎯 Target: ${{ secrets.SSH_HOST }}"
|
||||||
|
echo "🌿 Branch: ${{ github.ref_name }}"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# NOTIFICATION: Gotify
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
- name: 🔔 Gotify Notification (Success)
|
||||||
|
if: success()
|
||||||
|
run: |
|
||||||
|
echo "Sending success notification to Gotify..."
|
||||||
|
curl -k -s -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
|
-F "title=✅ Deployment Success: ${{ github.repository }}" \
|
||||||
|
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref_name }}) was successful.
|
||||||
|
|
||||||
|
Commit: ${{ github.sha }}
|
||||||
|
Actor: ${{ github.actor }}
|
||||||
|
Run ID: ${{ github.run_id }}" \
|
||||||
|
-F "priority=5"
|
||||||
|
|
||||||
|
- name: 🔔 Gotify Notification (Failure)
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "Sending failure notification to Gotify..."
|
||||||
|
curl -k -s -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
|
-F "title=❌ Deployment Failed: ${{ github.repository }}" \
|
||||||
|
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref_name }}) failed!
|
||||||
|
|
||||||
|
Commit: ${{ github.sha }}
|
||||||
|
Actor: ${{ github.actor }}
|
||||||
|
Run ID: ${{ github.run_id }}
|
||||||
|
|
||||||
|
Please check the logs for details." \
|
||||||
|
-F "priority=8"
|
||||||
12
packages/infra/package.json
Normal file
12
packages/infra/package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/infra",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"docker",
|
||||||
|
"gitea"
|
||||||
|
]
|
||||||
|
}
|
||||||
16
packages/next-config/README.md
Normal file
16
packages/next-config/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# @mintel/next-config
|
||||||
|
|
||||||
|
Shared Next.js configuration wrapper for Mintel projects. Integrates `next-intl` and Sentry by default.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
In your `next.config.ts`:
|
||||||
|
```typescript
|
||||||
|
import mintelNextConfig from "@mintel/next-config";
|
||||||
|
|
||||||
|
const nextConfig = {
|
||||||
|
// Your project specific config
|
||||||
|
};
|
||||||
|
|
||||||
|
export default mintelNextConfig(nextConfig);
|
||||||
|
```
|
||||||
46
packages/next-config/index.js
Normal file
46
packages/next-config/index.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
|
import { withSentryConfig } from '@sentry/nextjs';
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin();
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
export const baseNextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
images: {
|
||||||
|
dangerouslyAllowSVG: true,
|
||||||
|
contentDispositionType: 'attachment',
|
||||||
|
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||||
|
},
|
||||||
|
async rewrites() {
|
||||||
|
const umamiUrl = (process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL || 'https://analytics.infra.mintel.me').replace('/script.js', '');
|
||||||
|
const glitchtipUrl = process.env.SENTRY_DSN
|
||||||
|
? new URL(process.env.SENTRY_DSN).origin
|
||||||
|
: 'https://errors.infra.mintel.me';
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/stats/:path*',
|
||||||
|
destination: `${umamiUrl}/:path*`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/errors/:path*',
|
||||||
|
destination: `${glitchtipUrl}/:path*`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default (config) => {
|
||||||
|
const nextIntlConfig = withNextIntl({ ...baseNextConfig, ...config });
|
||||||
|
|
||||||
|
return withSentryConfig(
|
||||||
|
nextIntlConfig,
|
||||||
|
{
|
||||||
|
silent: !process.env.CI,
|
||||||
|
treeshake: { removeDebugLogging: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
authToken: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
14
packages/next-config/package.json
Normal file
14
packages/next-config/package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/next-config",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"next-intl": "^3.0.0",
|
||||||
|
"@sentry/nextjs": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
packages/next-utils/README.md
Normal file
28
packages/next-utils/README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# @mintel/next-utils
|
||||||
|
|
||||||
|
Reusable utilities for Mintel Next.js projects.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **i18n**: Standardized middleware and request configuration for `next-intl`.
|
||||||
|
- **Env Validation**: Zod-based environment variable validation.
|
||||||
|
- **Rate Limiting**: Simple in-memory rate limiting for server actions.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### i18n Middleware
|
||||||
|
```typescript
|
||||||
|
import { createMintelMiddleware } from "@mintel/next-utils";
|
||||||
|
|
||||||
|
export default createMintelMiddleware({
|
||||||
|
locales: ["en", "de"],
|
||||||
|
defaultLocale: "en",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Env Validation
|
||||||
|
```typescript
|
||||||
|
import { validateMintelEnv } from "@mintel/next-utils";
|
||||||
|
|
||||||
|
const env = validateMintelEnv();
|
||||||
|
```
|
||||||
27
packages/next-utils/package.json
Normal file
27
packages/next-utils/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/next-utils",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.mjs",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup src/index.ts --format cjs,esm --dts",
|
||||||
|
"dev": "tsup src/index.ts --format cjs,esm --watch --dts",
|
||||||
|
"lint": "eslint src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "15.1.6",
|
||||||
|
"next-intl": "^3.0.0",
|
||||||
|
"zod": "^3.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsup": "^8.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"@mintel/tsconfig": "workspace:*",
|
||||||
|
"@mintel/eslint-config": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
packages/next-utils/src/env.ts
Normal file
35
packages/next-utils/src/env.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const mintelEnvSchema = {
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
|
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||||
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
|
||||||
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().default('https://analytics.infra.mintel.me/script.js'),
|
||||||
|
SENTRY_DSN: z.string().optional(),
|
||||||
|
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||||
|
MAIL_HOST: z.string().optional(),
|
||||||
|
MAIL_PORT: z.coerce.number().default(587),
|
||||||
|
MAIL_USERNAME: z.string().optional(),
|
||||||
|
MAIL_PASSWORD: z.string().optional(),
|
||||||
|
MAIL_FROM: z.string().optional(),
|
||||||
|
MAIL_RECIPIENTS: z.preprocess(
|
||||||
|
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
|
||||||
|
z.array(z.string()).default([])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function validateMintelEnv(schemaExtension = {}) {
|
||||||
|
const fullSchema = z.object({
|
||||||
|
...mintelEnvSchema,
|
||||||
|
...schemaExtension,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = fullSchema.safeParse(process.env);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
|
||||||
|
throw new Error('Invalid environment variables');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
66
packages/next-utils/src/i18n.ts
Normal file
66
packages/next-utils/src/i18n.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import createMiddleware from 'next-intl/middleware';
|
||||||
|
import { getRequestConfig } from 'next-intl/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export interface MintelI18nConfig {
|
||||||
|
locales: string[];
|
||||||
|
defaultLocale: string;
|
||||||
|
logRequests?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMintelMiddleware(config: MintelI18nConfig) {
|
||||||
|
const intlMiddleware = createMiddleware({
|
||||||
|
locales: config.locales,
|
||||||
|
defaultLocale: config.defaultLocale,
|
||||||
|
});
|
||||||
|
|
||||||
|
return function middleware(request: NextRequest) {
|
||||||
|
if (config.logRequests) {
|
||||||
|
const { method, url } = request;
|
||||||
|
console.log(`Incoming request: method=${method} url=${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return intlMiddleware(request);
|
||||||
|
} catch (error) {
|
||||||
|
if (config.logRequests) {
|
||||||
|
console.error(`Request failed: ${request.method} ${request.url}`, error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMintelI18nRequestConfig(
|
||||||
|
locales: string[],
|
||||||
|
defaultLocale: string,
|
||||||
|
importMessages: (locale: string) => Promise<any>
|
||||||
|
) {
|
||||||
|
return getRequestConfig(async ({ requestLocale }) => {
|
||||||
|
let locale = await requestLocale;
|
||||||
|
|
||||||
|
if (!locale || !locales.includes(locale)) {
|
||||||
|
locale = defaultLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await importMessages(locale)).default,
|
||||||
|
onError(error: any) {
|
||||||
|
if (error.code === 'MISSING_MESSAGE') {
|
||||||
|
console.error(error.message);
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getMessageFallback({ namespace, key, error }: any) {
|
||||||
|
const path = [namespace, key].filter((part) => part != null).join('.');
|
||||||
|
if (error.code === 'MISSING_MESSAGE') {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return 'fallback';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
37
packages/next-utils/src/index.ts
Normal file
37
packages/next-utils/src/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Simple in-memory rate limiting
|
||||||
|
const submissions: Record<string, number> = {};
|
||||||
|
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour
|
||||||
|
const MAX_SUBMISSIONS_PER_WINDOW = 3;
|
||||||
|
|
||||||
|
export async function rateLimit(identifier: string, windowMs = RATE_LIMIT_WINDOW, maxSubmissions = MAX_SUBMISSIONS_PER_WINDOW) {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Clean up old submissions
|
||||||
|
Object.keys(submissions).forEach((key) => {
|
||||||
|
if (now - submissions[key] > windowMs) {
|
||||||
|
delete submissions[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if identifier has exceeded submission limit
|
||||||
|
const currentSubmissions = Object.values(submissions).filter(
|
||||||
|
(timestamp) => now - timestamp <= windowMs
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentSubmissions.length >= maxSubmissions) {
|
||||||
|
throw new Error("Too many submissions. Please try again later.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record this submission
|
||||||
|
submissions[identifier] = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languages = ["en", "de"] as const;
|
||||||
|
export type Lang = (typeof languages)[number];
|
||||||
|
|
||||||
|
export function isValidLang(lang: string): lang is Lang {
|
||||||
|
return (languages as readonly string[]).includes(lang);
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from "./i18n";
|
||||||
|
export * from "./env";
|
||||||
9
packages/next-utils/tsconfig.json
Normal file
9
packages/next-utils/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@mintel/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"lib": ["es2017", "dom"]
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
21
packages/tsconfig/README.md
Normal file
21
packages/tsconfig/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# @mintel/tsconfig
|
||||||
|
|
||||||
|
Shared TypeScript configurations for Mintel projects.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Base Configuration
|
||||||
|
In your `tsconfig.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"extends": "@mintel/tsconfig/base.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Next.js Configuration
|
||||||
|
In your `tsconfig.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"extends": "@mintel/tsconfig/nextjs.json"
|
||||||
|
}
|
||||||
|
```
|
||||||
19
packages/tsconfig/base.json
Normal file
19
packages/tsconfig/base.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"display": "Base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": false,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": false
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/tsconfig/nextjs.json
Normal file
12
packages/tsconfig/nextjs.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/tsconfig",
|
||||||
|
"display": "Next.js",
|
||||||
|
"extends": "./base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/tsconfig/package.json
Normal file
12
packages/tsconfig/package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@mintel/tsconfig",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"base.json",
|
||||||
|
"nextjs.json"
|
||||||
|
]
|
||||||
|
}
|
||||||
6987
pnpm-lock.yaml
generated
Normal file
6987
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- 'packages/*'
|
||||||
|
- 'apps/*'
|
||||||
Reference in New Issue
Block a user