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