Integrate Prisma for content; enhance SEO, i18n, and deployment workflows
Co-authored-by: dennis <dennis@konkol.net>
This commit is contained in:
@@ -108,6 +108,7 @@ jobs:
|
|||||||
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
||||||
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
|
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
|
||||||
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
|
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
|
||||||
|
ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }}
|
||||||
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
|
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
|
||||||
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
|
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ jobs:
|
|||||||
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
|
export MY_PASSWORD="${{ secrets.MY_PASSWORD }}"
|
||||||
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
|
export MY_INFO_PASSWORD="${{ secrets.MY_INFO_PASSWORD }}"
|
||||||
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
|
export ADMIN_BASIC_AUTH="${{ secrets.ADMIN_BASIC_AUTH }}"
|
||||||
|
export ADMIN_SESSION_SECRET="${{ secrets.ADMIN_SESSION_SECRET }}"
|
||||||
|
|
||||||
# Start new container with updated image (docker-compose will handle this)
|
# Start new container with updated image (docker-compose will handle this)
|
||||||
echo "🆕 Starting new production container..."
|
echo "🆕 Starting new production container..."
|
||||||
@@ -202,6 +203,7 @@ jobs:
|
|||||||
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
MY_PASSWORD: ${{ secrets.MY_PASSWORD }}
|
||||||
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
|
MY_INFO_PASSWORD: ${{ secrets.MY_INFO_PASSWORD }}
|
||||||
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
|
ADMIN_BASIC_AUTH: ${{ secrets.ADMIN_BASIC_AUTH }}
|
||||||
|
ADMIN_SESSION_SECRET: ${{ secrets.ADMIN_SESSION_SECRET }}
|
||||||
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
|
N8N_WEBHOOK_URL: ${{ vars.N8N_WEBHOOK_URL || '' }}
|
||||||
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
|
N8N_SECRET_TOKEN: ${{ secrets.N8N_SECRET_TOKEN || '' }}
|
||||||
N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }}
|
N8N_API_KEY: ${{ vars.N8N_API_KEY || '' }}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ Für den `production` Branch brauchst du:
|
|||||||
- `MY_PASSWORD` = Dein Email-Passwort
|
- `MY_PASSWORD` = Dein Email-Passwort
|
||||||
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort
|
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort
|
||||||
- `ADMIN_BASIC_AUTH` = `admin:dein_sicheres_passwort`
|
- `ADMIN_BASIC_AUTH` = `admin:dein_sicheres_passwort`
|
||||||
|
- `ADMIN_SESSION_SECRET` = zufälliger Secret (mind. 32 Zeichen)
|
||||||
- `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
|
- `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
|
||||||
|
|
||||||
## 🧪 Variablen für Dev Branch
|
## 🧪 Variablen für Dev Branch
|
||||||
@@ -42,6 +43,7 @@ Für den `testing` Branch brauchst du die **gleichen** Variablen, aber mit ander
|
|||||||
- `MY_PASSWORD` = Dein Email-Passwort (kann gleich sein)
|
- `MY_PASSWORD` = Dein Email-Passwort (kann gleich sein)
|
||||||
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort (kann gleich sein)
|
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort (kann gleich sein)
|
||||||
- `ADMIN_BASIC_AUTH` = `admin:testing_password` (kann anders sein)
|
- `ADMIN_BASIC_AUTH` = `admin:testing_password` (kann anders sein)
|
||||||
|
- `ADMIN_SESSION_SECRET` = zufälliger Secret (mind. 32 Zeichen; kann gleich sein)
|
||||||
- `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
|
- `N8N_SECRET_TOKEN` = Dein n8n Secret Token (optional)
|
||||||
|
|
||||||
## ✅ Lösung: Automatische Branch-Erkennung
|
## ✅ Lösung: Automatische Branch-Erkennung
|
||||||
@@ -89,6 +91,7 @@ Du musst **NICHTS** in Gitea setzen, es funktioniert automatisch!
|
|||||||
- `MY_PASSWORD` = Dein Email-Passwort
|
- `MY_PASSWORD` = Dein Email-Passwort
|
||||||
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort
|
- `MY_INFO_PASSWORD` = Dein Info-Email-Passwort
|
||||||
- `ADMIN_BASIC_AUTH` = `admin:dein_passwort`
|
- `ADMIN_BASIC_AUTH` = `admin:dein_passwort`
|
||||||
|
- `ADMIN_SESSION_SECRET` = zufälliger Secret (mind. 32 Zeichen)
|
||||||
- `N8N_SECRET_TOKEN` = Dein n8n Token (optional)
|
- `N8N_SECRET_TOKEN` = Dein n8n Token (optional)
|
||||||
|
|
||||||
**Optional setzen:**
|
**Optional setzen:**
|
||||||
|
|||||||
@@ -1,2 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||||
export { default } from "../../legal-notice/page";
|
export { default } from "../../legal-notice/page";
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
const languages = getLanguageAlternates({ pathWithoutLocale: "legal-notice" });
|
||||||
|
return {
|
||||||
|
alternates: {
|
||||||
|
canonical: toAbsoluteUrl(`/${locale}/legal-notice`),
|
||||||
|
languages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,23 @@
|
|||||||
export { default } from "../_ui/HomePage";
|
import type { Metadata } from "next";
|
||||||
|
import HomePage from "../_ui/HomePage";
|
||||||
|
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
const languages = getLanguageAlternates({ pathWithoutLocale: "" });
|
||||||
|
return {
|
||||||
|
alternates: {
|
||||||
|
canonical: toAbsoluteUrl(`/${locale}`),
|
||||||
|
languages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <HomePage />;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||||
export { default } from "../../privacy-policy/page";
|
export { default } from "../../privacy-policy/page";
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
const languages = getLanguageAlternates({ pathWithoutLocale: "privacy-policy" });
|
||||||
|
return {
|
||||||
|
alternates: {
|
||||||
|
canonical: toAbsoluteUrl(`/${locale}/privacy-policy`),
|
||||||
|
languages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import ProjectDetailClient from "@/app/_ui/ProjectDetailClient";
|
import ProjectDetailClient from "@/app/_ui/ProjectDetailClient";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; slug: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale, slug } = await params;
|
||||||
|
const languages = getLanguageAlternates({ pathWithoutLocale: `projects/${slug}` });
|
||||||
|
return {
|
||||||
|
alternates: {
|
||||||
|
canonical: toAbsoluteUrl(`/${locale}/projects/${slug}`),
|
||||||
|
languages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ProjectPage({
|
export default async function ProjectPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -1,8 +1,25 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import ProjectsPageClient from "@/app/_ui/ProjectsPageClient";
|
import ProjectsPageClient from "@/app/_ui/ProjectsPageClient";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import { getLanguageAlternates, toAbsoluteUrl } from "@/lib/seo";
|
||||||
|
|
||||||
export const revalidate = 300;
|
export const revalidate = 300;
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await params;
|
||||||
|
const languages = getLanguageAlternates({ pathWithoutLocale: "projects" });
|
||||||
|
return {
|
||||||
|
alternates: {
|
||||||
|
canonical: toAbsoluteUrl(`/${locale}/projects`),
|
||||||
|
languages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ProjectsPage({
|
export default async function ProjectsPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -1,43 +1,27 @@
|
|||||||
import { GET } from '@/app/api/fetchAllProjects/route';
|
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
// Wir mocken node-fetch direkt
|
jest.mock('@/lib/prisma', () => ({
|
||||||
jest.mock('node-fetch', () => ({
|
prisma: {
|
||||||
__esModule: true,
|
project: {
|
||||||
default: jest.fn(() =>
|
findMany: jest.fn(async () => [
|
||||||
Promise.resolve({
|
|
||||||
json: () =>
|
|
||||||
Promise.resolve({
|
|
||||||
posts: [
|
|
||||||
{
|
{
|
||||||
id: '67ac8dfa709c60000117d312',
|
id: 1,
|
||||||
title: 'Just Doing Some Testing',
|
|
||||||
meta_description: 'Hello bla bla bla bla',
|
|
||||||
slug: 'just-doing-some-testing',
|
slug: 'just-doing-some-testing',
|
||||||
updated_at: '2025-02-13T14:25:38.000+00:00',
|
title: 'Just Doing Some Testing',
|
||||||
|
updatedAt: new Date('2025-02-13T14:25:38.000Z'),
|
||||||
|
metaDescription: 'Hello bla bla bla bla',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '67aaffc3709c60000117d2d9',
|
id: 2,
|
||||||
title: 'Blockchain Based Voting System',
|
|
||||||
meta_description:
|
|
||||||
'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
|
|
||||||
slug: 'blockchain-based-voting-system',
|
slug: 'blockchain-based-voting-system',
|
||||||
updated_at: '2025-02-13T16:54:42.000+00:00',
|
title: 'Blockchain Based Voting System',
|
||||||
|
updatedAt: new Date('2025-02-13T16:54:42.000Z'),
|
||||||
|
metaDescription:
|
||||||
|
'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
|
||||||
},
|
},
|
||||||
],
|
]),
|
||||||
meta: {
|
|
||||||
pagination: {
|
|
||||||
limit: 'all',
|
|
||||||
next: null,
|
|
||||||
page: 1,
|
|
||||||
pages: 1,
|
|
||||||
prev: null,
|
|
||||||
total: 2,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('next/server', () => ({
|
jest.mock('next/server', () => ({
|
||||||
@@ -47,12 +31,8 @@ jest.mock('next/server', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('GET /api/fetchAllProjects', () => {
|
describe('GET /api/fetchAllProjects', () => {
|
||||||
beforeAll(() => {
|
|
||||||
process.env.GHOST_API_URL = 'http://localhost:2368';
|
|
||||||
process.env.GHOST_API_KEY = 'some-key';
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return a list of projects (partial match)', async () => {
|
it('should return a list of projects (partial match)', async () => {
|
||||||
|
const { GET } = await import('@/app/api/fetchAllProjects/route');
|
||||||
await GET();
|
await GET();
|
||||||
|
|
||||||
// Den tatsächlichen Argumentwert extrahieren
|
// Den tatsächlichen Argumentwert extrahieren
|
||||||
@@ -61,11 +41,11 @@ describe('GET /api/fetchAllProjects', () => {
|
|||||||
expect(responseArg).toMatchObject({
|
expect(responseArg).toMatchObject({
|
||||||
posts: expect.arrayContaining([
|
posts: expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: '67ac8dfa709c60000117d312',
|
id: '1',
|
||||||
title: 'Just Doing Some Testing',
|
title: 'Just Doing Some Testing',
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: '67aaffc3709c60000117d2d9',
|
id: '2',
|
||||||
title: 'Blockchain Based Voting System',
|
title: 'Blockchain Based Voting System',
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -1,26 +1,23 @@
|
|||||||
import { GET } from '@/app/api/fetchProject/route';
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
// Mock node-fetch so the route uses it as a reliable fallback
|
jest.mock('@/lib/prisma', () => ({
|
||||||
jest.mock('node-fetch', () => ({
|
prisma: {
|
||||||
__esModule: true,
|
project: {
|
||||||
default: jest.fn(() =>
|
findUnique: jest.fn(async ({ where }: { where: { slug: string } }) => {
|
||||||
Promise.resolve({
|
if (where.slug !== 'blockchain-based-voting-system') return null;
|
||||||
ok: true,
|
return {
|
||||||
json: () =>
|
id: 2,
|
||||||
Promise.resolve({
|
|
||||||
posts: [
|
|
||||||
{
|
|
||||||
id: '67aaffc3709c60000117d2d9',
|
|
||||||
title: 'Blockchain Based Voting System',
|
title: 'Blockchain Based Voting System',
|
||||||
meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
|
metaDescription:
|
||||||
|
'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
|
||||||
slug: 'blockchain-based-voting-system',
|
slug: 'blockchain-based-voting-system',
|
||||||
updated_at: '2025-02-13T16:54:42.000+00:00',
|
updatedAt: new Date('2025-02-13T16:54:42.000Z'),
|
||||||
},
|
description: null,
|
||||||
],
|
content: null,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
})
|
},
|
||||||
),
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('next/server', () => ({
|
jest.mock('next/server', () => ({
|
||||||
@@ -29,12 +26,8 @@ jest.mock('next/server', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
describe('GET /api/fetchProject', () => {
|
describe('GET /api/fetchProject', () => {
|
||||||
beforeAll(() => {
|
|
||||||
process.env.GHOST_API_URL = 'http://localhost:2368';
|
|
||||||
process.env.GHOST_API_KEY = 'some-key';
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fetch a project by slug', async () => {
|
it('should fetch a project by slug', async () => {
|
||||||
|
const { GET } = await import('@/app/api/fetchProject/route');
|
||||||
const mockRequest = {
|
const mockRequest = {
|
||||||
url: 'http://localhost/api/fetchProject?slug=blockchain-based-voting-system',
|
url: 'http://localhost/api/fetchProject?slug=blockchain-based-voting-system',
|
||||||
} as unknown as NextRequest;
|
} as unknown as NextRequest;
|
||||||
@@ -44,11 +37,11 @@ describe('GET /api/fetchProject', () => {
|
|||||||
expect(NextResponse.json).toHaveBeenCalledWith({
|
expect(NextResponse.json).toHaveBeenCalledWith({
|
||||||
posts: [
|
posts: [
|
||||||
{
|
{
|
||||||
id: '67aaffc3709c60000117d2d9',
|
id: '2',
|
||||||
title: 'Blockchain Based Voting System',
|
title: 'Blockchain Based Voting System',
|
||||||
meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
|
meta_description: 'This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.',
|
||||||
slug: 'blockchain-based-voting-system',
|
slug: 'blockchain-based-voting-system',
|
||||||
updated_at: '2025-02-13T16:54:42.000+00:00',
|
updated_at: '2025-02-13T16:54:42.000Z',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,77 +34,38 @@ jest.mock("next/server", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { GET } from "@/app/api/sitemap/route";
|
jest.mock("@/lib/sitemap", () => ({
|
||||||
|
getSitemapEntries: jest.fn(async () => [
|
||||||
// Mock node-fetch so we don't perform real network requests in tests
|
|
||||||
jest.mock("node-fetch", () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: jest.fn(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () =>
|
|
||||||
Promise.resolve({
|
|
||||||
posts: [
|
|
||||||
{
|
{
|
||||||
id: "67ac8dfa709c60000117d312",
|
url: "https://dki.one/en",
|
||||||
title: "Just Doing Some Testing",
|
lastModified: "2025-01-01T00:00:00.000Z",
|
||||||
meta_description: "Hello bla bla bla bla",
|
|
||||||
slug: "just-doing-some-testing",
|
|
||||||
updated_at: "2025-02-13T14:25:38.000+00:00",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "67aaffc3709c60000117d2d9",
|
url: "https://dki.one/de",
|
||||||
title: "Blockchain Based Voting System",
|
lastModified: "2025-01-01T00:00:00.000Z",
|
||||||
meta_description:
|
|
||||||
"This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.",
|
|
||||||
slug: "blockchain-based-voting-system",
|
|
||||||
updated_at: "2025-02-13T16:54:42.000+00:00",
|
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
meta: {
|
url: "https://dki.one/en/projects/blockchain-based-voting-system",
|
||||||
pagination: {
|
lastModified: "2025-02-13T16:54:42.000Z",
|
||||||
limit: "all",
|
|
||||||
next: null,
|
|
||||||
page: 1,
|
|
||||||
pages: 1,
|
|
||||||
prev: null,
|
|
||||||
total: 2,
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: "https://dki.one/de/projects/blockchain-based-voting-system",
|
||||||
|
lastModified: "2025-02-13T16:54:42.000Z",
|
||||||
},
|
},
|
||||||
}),
|
]),
|
||||||
}),
|
generateSitemapXml: jest.fn(
|
||||||
|
() =>
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://dki.one/en</loc></url></urlset>',
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("GET /api/sitemap", () => {
|
describe("GET /api/sitemap", () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
process.env.GHOST_API_URL = "http://localhost:2368";
|
|
||||||
process.env.GHOST_API_KEY = "test-api-key";
|
|
||||||
process.env.NEXT_PUBLIC_BASE_URL = "https://dki.one";
|
process.env.NEXT_PUBLIC_BASE_URL = "https://dki.one";
|
||||||
|
|
||||||
// Provide mock posts via env so route can use them without fetching
|
|
||||||
process.env.GHOST_MOCK_POSTS = JSON.stringify({
|
|
||||||
posts: [
|
|
||||||
{
|
|
||||||
id: "67ac8dfa709c60000117d312",
|
|
||||||
title: "Just Doing Some Testing",
|
|
||||||
meta_description: "Hello bla bla bla bla",
|
|
||||||
slug: "just-doing-some-testing",
|
|
||||||
updated_at: "2025-02-13T14:25:38.000+00:00",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "67aaffc3709c60000117d2d9",
|
|
||||||
title: "Blockchain Based Voting System",
|
|
||||||
meta_description:
|
|
||||||
"This project aims to revolutionize voting systems by leveraging blockchain to ensure security, transparency, and immutability.",
|
|
||||||
slug: "blockchain-based-voting-system",
|
|
||||||
updated_at: "2025-02-13T16:54:42.000+00:00",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return a sitemap", async () => {
|
it("should return a sitemap", async () => {
|
||||||
|
const { GET } = await import("@/app/api/sitemap/route");
|
||||||
const response = await GET();
|
const response = await GET();
|
||||||
|
|
||||||
// Get the body text from the NextResponse
|
// Get the body text from the NextResponse
|
||||||
@@ -113,15 +74,7 @@ describe("GET /api/sitemap", () => {
|
|||||||
expect(body).toContain(
|
expect(body).toContain(
|
||||||
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">',
|
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
);
|
);
|
||||||
expect(body).toContain("<loc>https://dki.one/</loc>");
|
expect(body).toContain("<loc>https://dki.one/en</loc>");
|
||||||
expect(body).toContain("<loc>https://dki.one/legal-notice</loc>");
|
|
||||||
expect(body).toContain("<loc>https://dki.one/privacy-policy</loc>");
|
|
||||||
expect(body).toContain(
|
|
||||||
"<loc>https://dki.one/projects/just-doing-some-testing</loc>",
|
|
||||||
);
|
|
||||||
expect(body).toContain(
|
|
||||||
"<loc>https://dki.one/projects/blockchain-based-voting-system</loc>",
|
|
||||||
);
|
|
||||||
// Note: Headers are not available in test environment
|
// Note: Headers are not available in test environment
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
import { GET } from "@/app/sitemap.xml/route";
|
|
||||||
|
|
||||||
jest.mock("next/server", () => ({
|
jest.mock("next/server", () => ({
|
||||||
NextResponse: jest.fn().mockImplementation((body: unknown, init?: ResponseInit) => {
|
NextResponse: jest.fn().mockImplementation((body: unknown, init?: ResponseInit) => {
|
||||||
@@ -11,71 +10,32 @@ jest.mock("next/server", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Sitemap XML used by node-fetch mock
|
jest.mock("@/lib/sitemap", () => ({
|
||||||
const sitemapXml = `
|
getSitemapEntries: jest.fn(async () => [
|
||||||
<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">
|
{
|
||||||
<url>
|
url: "https://dki.one/en",
|
||||||
<loc>https://dki.one/</loc>
|
lastModified: "2025-01-01T00:00:00.000Z",
|
||||||
</url>
|
},
|
||||||
<url>
|
]),
|
||||||
<loc>https://dki.one/legal-notice</loc>
|
generateSitemapXml: jest.fn(
|
||||||
</url>
|
() =>
|
||||||
<url>
|
'<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://dki.one/en</loc></url></urlset>',
|
||||||
<loc>https://dki.one/privacy-policy</loc>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://dki.one/projects/just-doing-some-testing</loc>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://dki.one/projects/blockchain-based-voting-system</loc>
|
|
||||||
</url>
|
|
||||||
</urlset>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Mock node-fetch for sitemap endpoint (hoisted by Jest)
|
|
||||||
jest.mock("node-fetch", () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: jest.fn((_url: string) =>
|
|
||||||
Promise.resolve({ ok: true, text: () => Promise.resolve(sitemapXml) }),
|
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Sitemap Component", () => {
|
describe("Sitemap Component", () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
process.env.NEXT_PUBLIC_BASE_URL = "https://dki.one";
|
process.env.NEXT_PUBLIC_BASE_URL = "https://dki.one";
|
||||||
|
|
||||||
// Provide sitemap XML directly so route uses it without fetching
|
|
||||||
process.env.GHOST_MOCK_SITEMAP = sitemapXml;
|
|
||||||
|
|
||||||
// Mock global.fetch too, to avoid any network calls
|
|
||||||
global.fetch = jest.fn().mockImplementation((url: string) => {
|
|
||||||
if (url.includes("/api/sitemap")) {
|
|
||||||
return Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
text: () => Promise.resolve(sitemapXml),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error(`Unknown URL: ${url}`));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should render the sitemap XML", async () => {
|
it("should render the sitemap XML", async () => {
|
||||||
|
const { GET } = await import("@/app/sitemap.xml/route");
|
||||||
const response = await GET();
|
const response = await GET();
|
||||||
|
|
||||||
expect(response.body).toContain(
|
expect(response.body).toContain(
|
||||||
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">',
|
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||||
);
|
);
|
||||||
expect(response.body).toContain("<loc>https://dki.one/</loc>");
|
expect(response.body).toContain("<loc>https://dki.one/en</loc>");
|
||||||
expect(response.body).toContain("<loc>https://dki.one/legal-notice</loc>");
|
|
||||||
expect(response.body).toContain(
|
|
||||||
"<loc>https://dki.one/privacy-policy</loc>",
|
|
||||||
);
|
|
||||||
expect(response.body).toContain(
|
|
||||||
"<loc>https://dki.one/projects/just-doing-some-testing</loc>",
|
|
||||||
);
|
|
||||||
expect(response.body).toContain(
|
|
||||||
"<loc>https://dki.one/projects/blockchain-based-voting-system</loc>",
|
|
||||||
);
|
|
||||||
// Note: Headers are not available in test environment
|
// Note: Headers are not available in test environment
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,66 +1,58 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
// Use a dynamic import for node-fetch so tests that mock it (via jest.mock) are respected
|
|
||||||
async function getFetch() {
|
|
||||||
try {
|
|
||||||
const mod = await import("node-fetch");
|
|
||||||
// support both CJS and ESM interop
|
|
||||||
return (mod as { default: unknown }).default ?? mod;
|
|
||||||
} catch (_err) {
|
|
||||||
return globalThis.fetch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const runtime = "nodejs"; // Force Node runtime
|
export const runtime = "nodejs"; // Force Node runtime
|
||||||
|
|
||||||
const GHOST_API_URL = process.env.GHOST_API_URL;
|
|
||||||
const GHOST_API_KEY = process.env.GHOST_API_KEY;
|
|
||||||
const cache = new NodeCache({ stdTTL: 300 }); // Cache für 5 Minuten
|
const cache = new NodeCache({ stdTTL: 300 }); // Cache für 5 Minuten
|
||||||
|
|
||||||
type GhostPost = {
|
type LegacyPost = {
|
||||||
slug: string;
|
slug: string;
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
feature_image: string;
|
meta_description: string | null;
|
||||||
visibility: string;
|
|
||||||
published_at: string;
|
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
html: string;
|
|
||||||
reading_time: number;
|
|
||||||
meta_description: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type GhostPostsResponse = {
|
type LegacyPostsResponse = {
|
||||||
posts: Array<GhostPost>;
|
posts: Array<LegacyPost>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const cacheKey = "ghostPosts";
|
const cacheKey = "projects:legacyPosts";
|
||||||
const cachedPosts = cache.get<GhostPostsResponse>(cacheKey);
|
const cachedPosts = cache.get<LegacyPostsResponse>(cacheKey);
|
||||||
|
|
||||||
if (cachedPosts) {
|
if (cachedPosts) {
|
||||||
return NextResponse.json(cachedPosts);
|
return NextResponse.json(cachedPosts);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fetchFn = await getFetch();
|
const projects = await prisma.project.findMany({
|
||||||
const response = await (fetchFn as unknown as typeof fetch)(
|
where: { published: true },
|
||||||
`${GHOST_API_URL}/ghost/api/content/posts/?key=${GHOST_API_KEY}&limit=all`,
|
orderBy: { updatedAt: "desc" },
|
||||||
);
|
select: {
|
||||||
const posts: GhostPostsResponse =
|
id: true,
|
||||||
(await response.json()) as GhostPostsResponse;
|
slug: true,
|
||||||
|
title: true,
|
||||||
|
updatedAt: true,
|
||||||
|
metaDescription: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!posts || !posts.posts) {
|
const payload: LegacyPostsResponse = {
|
||||||
console.error("Invalid posts data");
|
posts: projects.map((p) => ({
|
||||||
return NextResponse.json([]);
|
id: String(p.id),
|
||||||
}
|
slug: p.slug,
|
||||||
|
title: p.title,
|
||||||
|
meta_description: p.metaDescription ?? null,
|
||||||
|
updated_at: (p.updatedAt ?? new Date()).toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
cache.set(cacheKey, posts); // Daten im Cache speichern
|
cache.set(cacheKey, payload);
|
||||||
|
return NextResponse.json(payload);
|
||||||
return NextResponse.json(posts);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch posts from Ghost:", error);
|
console.error("Failed to fetch projects:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to fetch projects" },
|
{ error: "Failed to fetch projects" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export const runtime = "nodejs"; // Force Node runtime
|
export const runtime = "nodejs"; // Force Node runtime
|
||||||
|
|
||||||
const GHOST_API_URL = process.env.GHOST_API_URL;
|
|
||||||
const GHOST_API_KEY = process.env.GHOST_API_KEY;
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const slug = searchParams.get("slug");
|
const slug = searchParams.get("slug");
|
||||||
@@ -14,59 +12,37 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Debug: show whether fetch is present/mocked
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { slug },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
slug: true,
|
||||||
|
title: true,
|
||||||
|
updatedAt: true,
|
||||||
|
metaDescription: true,
|
||||||
|
description: true,
|
||||||
|
content: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
if (!project) {
|
||||||
console.log(
|
return NextResponse.json({ posts: [] }, { status: 200 });
|
||||||
"DEBUG fetch in fetchProject:",
|
|
||||||
typeof (globalThis as any).fetch,
|
|
||||||
"globalIsMock:",
|
|
||||||
!!(globalThis as any).fetch?._isMockFunction,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Try global fetch first (as tests often mock it). If it fails or returns undefined,
|
|
||||||
// fall back to dynamically importing node-fetch.
|
|
||||||
let response: any;
|
|
||||||
|
|
||||||
if (typeof (globalThis as any).fetch === "function") {
|
|
||||||
try {
|
|
||||||
response = await (globalThis as any).fetch(
|
|
||||||
`${GHOST_API_URL}/ghost/api/content/posts/slug/${slug}/?key=${GHOST_API_KEY}`,
|
|
||||||
);
|
|
||||||
} catch (_e) {
|
|
||||||
response = undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response || typeof response.ok === "undefined") {
|
// Legacy shape (Ghost-like) for compatibility with older frontend/tests.
|
||||||
try {
|
return NextResponse.json({
|
||||||
const mod = await import("node-fetch");
|
posts: [
|
||||||
const nodeFetch = (mod as any).default ?? mod;
|
{
|
||||||
response = await (nodeFetch as any)(
|
id: String(project.id),
|
||||||
`${GHOST_API_URL}/ghost/api/content/posts/slug/${slug}/?key=${GHOST_API_KEY}`,
|
title: project.title,
|
||||||
);
|
meta_description: project.metaDescription ?? project.description ?? "",
|
||||||
} catch (_err) {
|
slug: project.slug,
|
||||||
response = undefined;
|
updated_at: (project.updatedAt ?? new Date()).toISOString(),
|
||||||
}
|
},
|
||||||
}
|
],
|
||||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
});
|
||||||
|
|
||||||
// Debug: inspect the response returned from the fetch
|
|
||||||
|
|
||||||
// Debug: inspect the response returned from the fetch
|
|
||||||
|
|
||||||
console.log("DEBUG fetch response:", response);
|
|
||||||
|
|
||||||
if (!response || !response.ok) {
|
|
||||||
throw new Error(
|
|
||||||
`Failed to fetch post: ${response?.statusText ?? "no response"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const post = await response.json();
|
|
||||||
return NextResponse.json(post);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch post from Ghost:", error);
|
console.error("Failed to fetch project:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to fetch project" },
|
{ error: "Failed to fetch project" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|||||||
@@ -1,37 +1,132 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma, projectService } from '@/lib/prisma';
|
import { prisma, projectService } from "@/lib/prisma";
|
||||||
import { requireSessionAuth } from '@/lib/auth';
|
import { requireSessionAuth } from "@/lib/auth";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
type ImportSiteSettings = {
|
||||||
|
defaultLocale?: unknown;
|
||||||
|
locales?: unknown;
|
||||||
|
theme?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImportContentPageTranslation = {
|
||||||
|
locale?: unknown;
|
||||||
|
title?: unknown;
|
||||||
|
slug?: unknown;
|
||||||
|
content?: unknown;
|
||||||
|
metaDescription?: unknown;
|
||||||
|
keywords?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImportContentPage = {
|
||||||
|
key?: unknown;
|
||||||
|
status?: unknown;
|
||||||
|
translations?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImportProject = {
|
||||||
|
id?: unknown;
|
||||||
|
slug?: unknown;
|
||||||
|
defaultLocale?: unknown;
|
||||||
|
title?: unknown;
|
||||||
|
description?: unknown;
|
||||||
|
content?: unknown;
|
||||||
|
tags?: unknown;
|
||||||
|
category?: unknown;
|
||||||
|
featured?: unknown;
|
||||||
|
github?: unknown;
|
||||||
|
live?: unknown;
|
||||||
|
published?: unknown;
|
||||||
|
imageUrl?: unknown;
|
||||||
|
difficulty?: unknown;
|
||||||
|
timeToComplete?: unknown;
|
||||||
|
technologies?: unknown;
|
||||||
|
challenges?: unknown;
|
||||||
|
lessonsLearned?: unknown;
|
||||||
|
futureImprovements?: unknown;
|
||||||
|
demoVideo?: unknown;
|
||||||
|
screenshots?: unknown;
|
||||||
|
colorScheme?: unknown;
|
||||||
|
accessibility?: unknown;
|
||||||
|
performance?: unknown;
|
||||||
|
analytics?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImportProjectTranslation = {
|
||||||
|
projectId?: unknown;
|
||||||
|
locale?: unknown;
|
||||||
|
title?: unknown;
|
||||||
|
description?: unknown;
|
||||||
|
content?: unknown;
|
||||||
|
metaDescription?: unknown;
|
||||||
|
keywords?: unknown;
|
||||||
|
ogImage?: unknown;
|
||||||
|
schema?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ImportPayload = {
|
||||||
|
projects?: unknown;
|
||||||
|
siteSettings?: unknown;
|
||||||
|
contentPages?: unknown;
|
||||||
|
projectTranslations?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function asString(v: unknown): string | null {
|
||||||
|
return typeof v === "string" ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asStringArray(v: unknown): string[] | null {
|
||||||
|
if (!Array.isArray(v)) return null;
|
||||||
|
const allStrings = v.filter((x) => typeof x === "string") as string[];
|
||||||
|
return allStrings.length === v.length ? allStrings : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
const isAdminRequest = request.headers.get("x-admin-request") === "true";
|
||||||
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
if (!isAdminRequest) {
|
||||||
|
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
|
||||||
|
}
|
||||||
const authError = requireSessionAuth(request);
|
const authError = requireSessionAuth(request);
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|
||||||
const body = await request.json();
|
const body = (await request.json()) as ImportPayload;
|
||||||
|
|
||||||
// Validate import data structure
|
// Validate import data structure
|
||||||
if (!body.projects || !Array.isArray(body.projects)) {
|
if (!Array.isArray(body.projects)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid import data format' },
|
{ error: "Invalid import data format" },
|
||||||
{ status: 400 }
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = {
|
const results = {
|
||||||
imported: 0,
|
imported: 0,
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
errors: [] as string[]
|
errors: [] as string[],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Import SiteSettings (optional)
|
// Import SiteSettings (optional)
|
||||||
if (body.siteSettings && typeof body.siteSettings === 'object') {
|
if (body.siteSettings && typeof body.siteSettings === "object") {
|
||||||
try {
|
try {
|
||||||
|
const ss = body.siteSettings as ImportSiteSettings;
|
||||||
|
const defaultLocale = asString(ss.defaultLocale);
|
||||||
|
const locales = asStringArray(ss.locales);
|
||||||
|
const theme = ss.theme as Prisma.InputJsonValue | undefined;
|
||||||
|
|
||||||
await prisma.siteSettings.upsert({
|
await prisma.siteSettings.upsert({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
create: { id: 1, ...(body.siteSettings as Record<string, unknown>) } as any,
|
create: {
|
||||||
update: { ...(body.siteSettings as Record<string, unknown>) } as any,
|
id: 1,
|
||||||
|
...(defaultLocale ? { defaultLocale } : {}),
|
||||||
|
...(locales ? { locales } : {}),
|
||||||
|
...(theme ? { theme } : {}),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
...(defaultLocale ? { defaultLocale } : {}),
|
||||||
|
...(locales ? { locales } : {}),
|
||||||
|
...(theme ? { theme } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// non-blocking
|
// non-blocking
|
||||||
@@ -42,39 +137,47 @@ export async function POST(request: NextRequest) {
|
|||||||
if (Array.isArray(body.contentPages)) {
|
if (Array.isArray(body.contentPages)) {
|
||||||
for (const page of body.contentPages) {
|
for (const page of body.contentPages) {
|
||||||
try {
|
try {
|
||||||
if (!page?.key) continue;
|
const key = asString((page as ImportContentPage)?.key);
|
||||||
|
if (!key) continue;
|
||||||
|
const statusRaw = asString((page as ImportContentPage)?.status);
|
||||||
|
const status = statusRaw === "DRAFT" || statusRaw === "PUBLISHED" ? statusRaw : "PUBLISHED";
|
||||||
const upserted = await prisma.contentPage.upsert({
|
const upserted = await prisma.contentPage.upsert({
|
||||||
where: { key: page.key },
|
where: { key },
|
||||||
create: { key: page.key, status: page.status || 'PUBLISHED' },
|
create: { key, status },
|
||||||
update: { status: page.status || 'PUBLISHED' },
|
update: { status },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Array.isArray(page.translations)) {
|
const translations = (page as ImportContentPage)?.translations;
|
||||||
for (const tr of page.translations) {
|
if (Array.isArray(translations)) {
|
||||||
if (!tr?.locale || !tr?.content) continue;
|
for (const tr of translations as ImportContentPageTranslation[]) {
|
||||||
|
const locale = asString(tr?.locale);
|
||||||
|
if (!locale || typeof tr?.content === "undefined" || tr?.content === null) continue;
|
||||||
await prisma.contentPageTranslation.upsert({
|
await prisma.contentPageTranslation.upsert({
|
||||||
where: { pageId_locale: { pageId: upserted.id, locale: tr.locale } },
|
where: { pageId_locale: { pageId: upserted.id, locale } },
|
||||||
create: {
|
create: {
|
||||||
pageId: upserted.id,
|
pageId: upserted.id,
|
||||||
locale: tr.locale,
|
locale,
|
||||||
title: tr.title || null,
|
title: asString(tr.title),
|
||||||
slug: tr.slug || null,
|
slug: asString(tr.slug),
|
||||||
content: tr.content,
|
content: tr.content as Prisma.InputJsonValue,
|
||||||
metaDescription: tr.metaDescription || null,
|
metaDescription: asString(tr.metaDescription),
|
||||||
keywords: tr.keywords || null,
|
keywords: asString(tr.keywords),
|
||||||
} as any,
|
},
|
||||||
update: {
|
update: {
|
||||||
title: tr.title || null,
|
title: asString(tr.title),
|
||||||
slug: tr.slug || null,
|
slug: asString(tr.slug),
|
||||||
content: tr.content,
|
content: tr.content as Prisma.InputJsonValue,
|
||||||
metaDescription: tr.metaDescription || null,
|
metaDescription: asString(tr.metaDescription),
|
||||||
keywords: tr.keywords || null,
|
keywords: asString(tr.keywords),
|
||||||
} as any,
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.errors.push(`Failed to import content page "${page?.key}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
const key = asString((page as ImportContentPage)?.key) ?? "unknown";
|
||||||
|
results.errors.push(
|
||||||
|
`Failed to import content page "${key}": ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,102 +186,124 @@ export async function POST(request: NextRequest) {
|
|||||||
const existingProjectsResult = await projectService.getAllProjects({ limit: 10000 });
|
const existingProjectsResult = await projectService.getAllProjects({ limit: 10000 });
|
||||||
const existingProjects = existingProjectsResult.projects || existingProjectsResult;
|
const existingProjects = existingProjectsResult.projects || existingProjectsResult;
|
||||||
const existingTitles = new Set(existingProjects.map(p => p.title));
|
const existingTitles = new Set(existingProjects.map(p => p.title));
|
||||||
const existingSlugs = new Set(existingProjects.map(p => (p as unknown as { slug?: string }).slug).filter(Boolean));
|
const existingSlugs = new Set(
|
||||||
|
existingProjects
|
||||||
|
.map((p) => (p as unknown as { slug?: string }).slug)
|
||||||
|
.filter((s): s is string => typeof s === "string" && s.length > 0),
|
||||||
|
);
|
||||||
|
|
||||||
// Process each project
|
// Process each project
|
||||||
for (const projectData of body.projects) {
|
for (const projectData of body.projects as ImportProject[]) {
|
||||||
try {
|
try {
|
||||||
// Check if project already exists (by title)
|
// Check if project already exists (by title)
|
||||||
const exists = existingTitles.has(projectData.title);
|
const title = asString(projectData.title);
|
||||||
|
if (!title) continue;
|
||||||
|
const exists = existingTitles.has(title);
|
||||||
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
results.skipped++;
|
results.skipped++;
|
||||||
results.errors.push(`Project "${projectData.title}" already exists`);
|
results.errors.push(`Project "${title}" already exists`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new project
|
// Create new project
|
||||||
const created = await projectService.createProject({
|
const created = await projectService.createProject({
|
||||||
slug: projectData.slug,
|
slug: asString(projectData.slug) ?? undefined,
|
||||||
defaultLocale: projectData.defaultLocale || 'en',
|
defaultLocale: asString(projectData.defaultLocale) ?? "en",
|
||||||
title: projectData.title,
|
title,
|
||||||
description: projectData.description,
|
description: asString(projectData.description) ?? "",
|
||||||
content: projectData.content,
|
content: projectData.content as Prisma.InputJsonValue | undefined,
|
||||||
tags: projectData.tags || [],
|
tags: (asStringArray(projectData.tags) ?? []) as string[],
|
||||||
category: projectData.category,
|
category: asString(projectData.category) ?? "General",
|
||||||
featured: projectData.featured || false,
|
featured: projectData.featured === true,
|
||||||
github: projectData.github,
|
github: asString(projectData.github) ?? undefined,
|
||||||
live: projectData.live,
|
live: asString(projectData.live) ?? undefined,
|
||||||
published: projectData.published !== false, // Default to true
|
published: projectData.published !== false, // Default to true
|
||||||
imageUrl: projectData.imageUrl,
|
imageUrl: asString(projectData.imageUrl) ?? undefined,
|
||||||
difficulty: projectData.difficulty || 'Intermediate',
|
difficulty: asString(projectData.difficulty) ?? "Intermediate",
|
||||||
timeToComplete: projectData.timeToComplete,
|
timeToComplete: asString(projectData.timeToComplete) ?? undefined,
|
||||||
technologies: projectData.technologies || [],
|
technologies: (asStringArray(projectData.technologies) ?? []) as string[],
|
||||||
challenges: projectData.challenges || [],
|
challenges: (asStringArray(projectData.challenges) ?? []) as string[],
|
||||||
lessonsLearned: projectData.lessonsLearned || [],
|
lessonsLearned: (asStringArray(projectData.lessonsLearned) ?? []) as string[],
|
||||||
futureImprovements: projectData.futureImprovements || [],
|
futureImprovements: (asStringArray(projectData.futureImprovements) ?? []) as string[],
|
||||||
demoVideo: projectData.demoVideo,
|
demoVideo: asString(projectData.demoVideo) ?? undefined,
|
||||||
screenshots: projectData.screenshots || [],
|
screenshots: (asStringArray(projectData.screenshots) ?? []) as string[],
|
||||||
colorScheme: projectData.colorScheme || 'Dark',
|
colorScheme: asString(projectData.colorScheme) ?? "Dark",
|
||||||
accessibility: projectData.accessibility !== false, // Default to true
|
accessibility: projectData.accessibility !== false, // Default to true
|
||||||
performance: projectData.performance || {
|
performance: (projectData.performance as Record<string, unknown> | null) || {
|
||||||
lighthouse: 0,
|
lighthouse: 0,
|
||||||
bundleSize: '0KB',
|
bundleSize: "0KB",
|
||||||
loadTime: '0s'
|
loadTime: "0s",
|
||||||
},
|
},
|
||||||
analytics: projectData.analytics || {
|
analytics: (projectData.analytics as Record<string, unknown> | null) || {
|
||||||
views: 0,
|
views: 0,
|
||||||
likes: 0,
|
likes: 0,
|
||||||
shares: 0
|
shares: 0,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Import translations (optional, from export v2)
|
// Import translations (optional, from export v2)
|
||||||
if (Array.isArray(body.projectTranslations)) {
|
if (Array.isArray(body.projectTranslations)) {
|
||||||
for (const tr of body.projectTranslations) {
|
for (const tr of body.projectTranslations as ImportProjectTranslation[]) {
|
||||||
if (!tr?.projectId || !tr?.locale) continue;
|
const projectId = typeof tr?.projectId === "number" ? tr.projectId : null;
|
||||||
|
const locale = asString(tr?.locale);
|
||||||
|
if (!projectId || !locale) continue;
|
||||||
// Map translation to created project by original slug/title when possible.
|
// Map translation to created project by original slug/title when possible.
|
||||||
// We match by slug if available in exported project list; otherwise by title.
|
// We match by slug if available in exported project list; otherwise by title.
|
||||||
const exportedProject = body.projects.find((p: any) => p.id === tr.projectId);
|
const exportedProject = (body.projects as ImportProject[]).find(
|
||||||
const exportedSlug = exportedProject?.slug;
|
(p) => typeof p.id === "number" && p.id === projectId,
|
||||||
|
);
|
||||||
|
const exportedSlug = asString(exportedProject?.slug);
|
||||||
const matches =
|
const matches =
|
||||||
(exportedSlug && (created as any).slug === exportedSlug) ||
|
(exportedSlug && (created as unknown as { slug?: string }).slug === exportedSlug) ||
|
||||||
(!!exportedProject?.title && (created as any).title === exportedProject.title);
|
(!!asString(exportedProject?.title) &&
|
||||||
|
(created as unknown as { title?: string }).title === asString(exportedProject?.title));
|
||||||
if (!matches) continue;
|
if (!matches) continue;
|
||||||
|
|
||||||
if (!tr.title || !tr.description) continue;
|
const trTitle = asString(tr.title);
|
||||||
|
const trDescription = asString(tr.description);
|
||||||
|
if (!trTitle || !trDescription) continue;
|
||||||
await prisma.projectTranslation.upsert({
|
await prisma.projectTranslation.upsert({
|
||||||
where: { projectId_locale: { projectId: (created as any).id, locale: tr.locale } },
|
where: {
|
||||||
|
projectId_locale: {
|
||||||
|
projectId: (created as unknown as { id: number }).id,
|
||||||
|
locale,
|
||||||
|
},
|
||||||
|
},
|
||||||
create: {
|
create: {
|
||||||
projectId: (created as any).id,
|
projectId: (created as unknown as { id: number }).id,
|
||||||
locale: tr.locale,
|
locale,
|
||||||
title: tr.title,
|
title: trTitle,
|
||||||
description: tr.description,
|
description: trDescription,
|
||||||
content: tr.content || null,
|
content: (tr.content as Prisma.InputJsonValue) ?? null,
|
||||||
metaDescription: tr.metaDescription || null,
|
metaDescription: asString(tr.metaDescription),
|
||||||
keywords: tr.keywords || null,
|
keywords: asString(tr.keywords),
|
||||||
ogImage: tr.ogImage || null,
|
ogImage: asString(tr.ogImage),
|
||||||
schema: tr.schema || null,
|
schema: (tr.schema as Prisma.InputJsonValue) ?? null,
|
||||||
} as any,
|
},
|
||||||
update: {
|
update: {
|
||||||
title: tr.title,
|
title: trTitle,
|
||||||
description: tr.description,
|
description: trDescription,
|
||||||
content: tr.content || null,
|
content: (tr.content as Prisma.InputJsonValue) ?? null,
|
||||||
metaDescription: tr.metaDescription || null,
|
metaDescription: asString(tr.metaDescription),
|
||||||
keywords: tr.keywords || null,
|
keywords: asString(tr.keywords),
|
||||||
ogImage: tr.ogImage || null,
|
ogImage: asString(tr.ogImage),
|
||||||
schema: tr.schema || null,
|
schema: (tr.schema as Prisma.InputJsonValue) ?? null,
|
||||||
} as any,
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
results.imported++;
|
results.imported++;
|
||||||
existingTitles.add(projectData.title);
|
existingTitles.add(title);
|
||||||
if (projectData.slug) existingSlugs.add(projectData.slug);
|
const slug = asString(projectData.slug);
|
||||||
|
if (slug) existingSlugs.add(slug);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.skipped++;
|
results.skipped++;
|
||||||
results.errors.push(`Failed to import "${projectData.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
const title = asString(projectData.title) ?? "unknown";
|
||||||
|
results.errors.push(
|
||||||
|
`Failed to import "${title}": ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,10 +313,10 @@ export async function POST(request: NextRequest) {
|
|||||||
results
|
results
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Import error:', error);
|
console.error("Import error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Failed to import projects' },
|
{ error: "Failed to import projects" },
|
||||||
{ status: 500 }
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,164 +1,22 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { generateSitemapXml, getSitemapEntries } from "@/lib/sitemap";
|
||||||
interface Project {
|
|
||||||
slug: string;
|
|
||||||
updated_at?: string; // Optional timestamp for last modification
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectsData {
|
|
||||||
posts: Project[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const runtime = "nodejs"; // Force Node runtime
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
// Read Ghost API config at runtime, tests may set env vars in beforeAll
|
|
||||||
|
|
||||||
// Funktion, um die XML für die Sitemap zu generieren
|
|
||||||
function generateXml(sitemapRoutes: { url: string; lastModified: string }[]) {
|
|
||||||
const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>';
|
|
||||||
const urlsetOpen =
|
|
||||||
'<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">';
|
|
||||||
const urlsetClose = "</urlset>";
|
|
||||||
|
|
||||||
const urlEntries = sitemapRoutes
|
|
||||||
.map(
|
|
||||||
(route) => `
|
|
||||||
<url>
|
|
||||||
<loc>${route.url}</loc>
|
|
||||||
<lastmod>${route.lastModified}</lastmod>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.8</priority>
|
|
||||||
</url>`,
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
return `${xmlHeader}${urlsetOpen}${urlEntries}${urlsetClose}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
|
try {
|
||||||
|
const entries = await getSitemapEntries();
|
||||||
// Statische Routen
|
const xml = generateSitemapXml(entries);
|
||||||
const staticRoutes = [
|
|
||||||
{
|
|
||||||
url: `${baseUrl}/`,
|
|
||||||
lastModified: new Date().toISOString(),
|
|
||||||
priority: 1,
|
|
||||||
changeFreq: "weekly",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: `${baseUrl}/legal-notice`,
|
|
||||||
lastModified: new Date().toISOString(),
|
|
||||||
priority: 0.5,
|
|
||||||
changeFreq: "yearly",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: `${baseUrl}/privacy-policy`,
|
|
||||||
lastModified: new Date().toISOString(),
|
|
||||||
priority: 0.5,
|
|
||||||
changeFreq: "yearly",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// In test environment we can short-circuit and use a mocked posts payload
|
|
||||||
if (process.env.NODE_ENV === "test" && process.env.GHOST_MOCK_POSTS) {
|
|
||||||
const mockData = JSON.parse(process.env.GHOST_MOCK_POSTS);
|
|
||||||
const projects = (mockData as ProjectsData).posts || [];
|
|
||||||
|
|
||||||
const sitemapRoutes = projects.map((project) => {
|
|
||||||
const lastModified = project.updated_at || new Date().toISOString();
|
|
||||||
return {
|
|
||||||
url: `${baseUrl}/projects/${project.slug}`,
|
|
||||||
lastModified,
|
|
||||||
priority: 0.8,
|
|
||||||
changeFreq: "monthly",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const allRoutes = [...staticRoutes, ...sitemapRoutes];
|
|
||||||
const xml = generateXml(allRoutes);
|
|
||||||
|
|
||||||
// For tests return a plain object so tests can inspect `.body` easily
|
|
||||||
if (process.env.NODE_ENV === "test") {
|
|
||||||
return new NextResponse(xml, {
|
return new NextResponse(xml, {
|
||||||
headers: { "Content-Type": "application/xml" },
|
headers: { "Content-Type": "application/xml" },
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return new NextResponse(xml, {
|
|
||||||
headers: { "Content-Type": "application/xml" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Debug: show whether fetch is present/mocked
|
|
||||||
|
|
||||||
// Try global fetch first (tests may mock global.fetch)
|
|
||||||
let response: Response | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (typeof globalThis.fetch === "function") {
|
|
||||||
response = await globalThis.fetch(
|
|
||||||
`${process.env.GHOST_API_URL}/ghost/api/content/posts/?key=${process.env.GHOST_API_KEY}&limit=all`,
|
|
||||||
);
|
|
||||||
// Debug: inspect the result
|
|
||||||
|
|
||||||
console.log("DEBUG sitemap global fetch returned:", response);
|
|
||||||
}
|
|
||||||
} catch (_e) {
|
|
||||||
response = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response || typeof response.ok === "undefined" || !response.ok) {
|
|
||||||
try {
|
|
||||||
const mod = await import("node-fetch");
|
|
||||||
const nodeFetch = mod.default ?? mod;
|
|
||||||
response = await (nodeFetch as unknown as typeof fetch)(
|
|
||||||
`${process.env.GHOST_API_URL}/ghost/api/content/posts/?key=${process.env.GHOST_API_KEY}&limit=all`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.log("Failed to fetch posts from Ghost:", err);
|
|
||||||
return new NextResponse(generateXml(staticRoutes), {
|
|
||||||
headers: { "Content-Type": "application/xml" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response || !response.ok) {
|
|
||||||
console.error(
|
|
||||||
`Failed to fetch posts: ${response?.statusText ?? "no response"}`,
|
|
||||||
);
|
|
||||||
return new NextResponse(generateXml(staticRoutes), {
|
|
||||||
headers: { "Content-Type": "application/xml" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectsData = (await response.json()) as ProjectsData;
|
|
||||||
|
|
||||||
const projects = projectsData.posts;
|
|
||||||
|
|
||||||
// Dynamische Projekt-Routen generieren
|
|
||||||
const sitemapRoutes = projects.map((project) => {
|
|
||||||
const lastModified = project.updated_at || new Date().toISOString();
|
|
||||||
return {
|
|
||||||
url: `${baseUrl}/projects/${project.slug}`,
|
|
||||||
lastModified,
|
|
||||||
priority: 0.8,
|
|
||||||
changeFreq: "monthly",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const allRoutes = [...staticRoutes, ...sitemapRoutes];
|
|
||||||
|
|
||||||
// Rückgabe der Sitemap im XML-Format
|
|
||||||
return new NextResponse(generateXml(allRoutes), {
|
|
||||||
headers: { "Content-Type": "application/xml" },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Failed to fetch posts from Ghost:", error);
|
console.error("Failed to generate sitemap:", error);
|
||||||
// Rückgabe der statischen Routen, falls Fehler auftritt
|
// Fail closed: return minimal sitemap
|
||||||
return new NextResponse(generateXml(staticRoutes), {
|
const xml = generateSitemapXml([]);
|
||||||
|
return new NextResponse(xml, {
|
||||||
|
status: 500,
|
||||||
headers: { "Content-Type": "application/xml" },
|
headers: { "Content-Type": "application/xml" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ export default function ConsentBanner() {
|
|||||||
const { consent, setConsent } = useConsent();
|
const { consent, setConsent } = useConsent();
|
||||||
const [draft, setDraft] = useState<ConsentState>({ analytics: false, chat: false });
|
const [draft, setDraft] = useState<ConsentState>({ analytics: false, chat: false });
|
||||||
|
|
||||||
const shouldShow = useMemo(() => consent === null, [consent]);
|
|
||||||
if (!shouldShow) return null;
|
|
||||||
|
|
||||||
const locale = useMemo(() => {
|
const locale = useMemo(() => {
|
||||||
if (typeof document === "undefined") return "en";
|
if (typeof document === "undefined") return "en";
|
||||||
const match = document.cookie
|
const match = document.cookie
|
||||||
@@ -20,6 +17,9 @@ export default function ConsentBanner() {
|
|||||||
return decodeURIComponent(match.split("=").slice(1).join("=")) || "en";
|
return decodeURIComponent(match.split("=").slice(1).join("=")) || "en";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const shouldShow = consent === null;
|
||||||
|
if (!shouldShow) return null;
|
||||||
|
|
||||||
const s = locale === "de"
|
const s = locale === "de"
|
||||||
? {
|
? {
|
||||||
title: "Datenschutz-Einstellungen",
|
title: "Datenschutz-Einstellungen",
|
||||||
|
|||||||
@@ -26,21 +26,21 @@ const KernelPanic404 = dynamic(() => import("./components/KernelPanic404Wrapper"
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
// In tests, avoid next/dynamic loadable timing and render a stable fallback
|
|
||||||
if (process.env.NODE_ENV === "test") {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
Oops! The page you're looking for doesn't exist.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// In tests, avoid next/dynamic loadable timing and render a stable fallback
|
||||||
|
if (process.env.NODE_ENV === "test") {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
Oops! The page you're looking for doesn't exist.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!mounted) {
|
if (!mounted) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
25
app/robots.txt/route.ts
Normal file
25
app/robots.txt/route.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getBaseUrl } from "@/lib/seo";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const base = getBaseUrl();
|
||||||
|
const body = [
|
||||||
|
"User-agent: *",
|
||||||
|
"Allow: /",
|
||||||
|
"Disallow: /api/",
|
||||||
|
"Disallow: /manage",
|
||||||
|
"Disallow: /editor",
|
||||||
|
`Sitemap: ${base}/sitemap.xml`,
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return new NextResponse(body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
"Cache-Control": "public, max-age=3600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,67 +1,20 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { generateSitemapXml, getSitemapEntries } from "@/lib/sitemap";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
|
|
||||||
const apiUrl = `${baseUrl}/api/sitemap`; // Verwende die vollständige URL zur API
|
|
||||||
|
|
||||||
// In test runs, allow returning a mocked sitemap explicitly
|
|
||||||
if (process.env.NODE_ENV === "test" && process.env.GHOST_MOCK_SITEMAP) {
|
|
||||||
// For tests return a simple object so tests can inspect `.body`
|
|
||||||
if (process.env.NODE_ENV === "test") {
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
return {
|
|
||||||
body: process.env.GHOST_MOCK_SITEMAP,
|
|
||||||
headers: { "Content-Type": "application/xml" },
|
|
||||||
} as any;
|
|
||||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
||||||
}
|
|
||||||
return new NextResponse(process.env.GHOST_MOCK_SITEMAP, {
|
|
||||||
headers: { "Content-Type": "application/xml" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Holt die Sitemap-Daten von der API
|
const entries = await getSitemapEntries();
|
||||||
// Try global fetch first, then fall back to node-fetch
|
const xml = generateSitemapXml(entries);
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
let res: any;
|
|
||||||
try {
|
|
||||||
if (typeof (globalThis as any).fetch === "function") {
|
|
||||||
res = await (globalThis as any).fetch(apiUrl);
|
|
||||||
}
|
|
||||||
} catch (_e) {
|
|
||||||
res = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res || typeof res.ok === "undefined" || !res.ok) {
|
|
||||||
try {
|
|
||||||
const mod = await import("node-fetch");
|
|
||||||
const nodeFetch = (mod as any).default ?? mod;
|
|
||||||
res = await (nodeFetch as any)(apiUrl);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error fetching sitemap:", err);
|
|
||||||
return new NextResponse("Error fetching sitemap", { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
||||||
|
|
||||||
if (!res || !res.ok) {
|
|
||||||
console.error(
|
|
||||||
`Failed to fetch sitemap: ${res?.statusText ?? "no response"}`,
|
|
||||||
);
|
|
||||||
return new NextResponse("Failed to fetch sitemap", { status: 500 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const xml = await res.text();
|
|
||||||
|
|
||||||
// Gibt die XML mit dem richtigen Content-Type zurück
|
|
||||||
return new NextResponse(xml, {
|
return new NextResponse(xml, {
|
||||||
headers: { "Content-Type": "application/xml" },
|
headers: { "Content-Type": "application/xml" },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching sitemap:", error);
|
console.error("Error generating sitemap.xml:", error);
|
||||||
return new NextResponse("Error fetching sitemap", { status: 500 });
|
return new NextResponse(generateSitemapXml([]), {
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/xml" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ services:
|
|||||||
- MY_PASSWORD=${MY_PASSWORD}
|
- MY_PASSWORD=${MY_PASSWORD}
|
||||||
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
|
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
|
||||||
- ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:your_secure_password_here}
|
- ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:your_secure_password_here}
|
||||||
|
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
||||||
|
# If you already have an existing DB (pre-migrations), set this to true ONCE to baseline.
|
||||||
|
- PRISMA_AUTO_BASELINE=${PRISMA_AUTO_BASELINE:-false}
|
||||||
- LOG_LEVEL=info
|
- LOG_LEVEL=info
|
||||||
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
|
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
|
||||||
- N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-}
|
- N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ services:
|
|||||||
- MY_PASSWORD=${MY_PASSWORD}
|
- MY_PASSWORD=${MY_PASSWORD}
|
||||||
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
|
- MY_INFO_PASSWORD=${MY_INFO_PASSWORD}
|
||||||
- ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:testing_password}
|
- ADMIN_BASIC_AUTH=${ADMIN_BASIC_AUTH:-admin:testing_password}
|
||||||
|
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
||||||
|
- PRISMA_AUTO_BASELINE=${PRISMA_AUTO_BASELINE:-false}
|
||||||
- LOG_LEVEL=info
|
- LOG_LEVEL=info
|
||||||
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
|
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-}
|
||||||
- N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-}
|
- N8N_SECRET_TOKEN=${N8N_SECRET_TOKEN:-}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ Dann SSL Zertifikate (Let’s Encrypt) aktivieren.
|
|||||||
- `MY_PASSWORD`
|
- `MY_PASSWORD`
|
||||||
- `MY_INFO_PASSWORD`
|
- `MY_INFO_PASSWORD`
|
||||||
- `ADMIN_BASIC_AUTH` (z.B. `admin:<starkes_passwort>`)
|
- `ADMIN_BASIC_AUTH` (z.B. `admin:<starkes_passwort>`)
|
||||||
|
- `ADMIN_SESSION_SECRET` (mind. 32 Zeichen, zufällig; für Session-Login im Admin)
|
||||||
- optional: `N8N_SECRET_TOKEN`
|
- optional: `N8N_SECRET_TOKEN`
|
||||||
|
|
||||||
## Docker Compose Files
|
## Docker Compose Files
|
||||||
@@ -50,3 +51,19 @@ Dann SSL Zertifikate (Let’s Encrypt) aktivieren.
|
|||||||
|
|
||||||
Wenn du “dev” nicht mehr brauchst, kannst du den Branch einfach nicht mehr benutzen.
|
Wenn du “dev” nicht mehr brauchst, kannst du den Branch einfach nicht mehr benutzen.
|
||||||
|
|
||||||
|
## Prisma Migrations (Auto-Deploy)
|
||||||
|
|
||||||
|
Der App-Container führt beim Start automatisch aus:
|
||||||
|
- `prisma migrate deploy`
|
||||||
|
|
||||||
|
### Wichtig: bestehende Datenbank (Baseline)
|
||||||
|
Wenn deine DB bereits existiert (vor Einführung von Prisma Migrations), dann würde die initiale Migration sonst mit “table already exists” scheitern.
|
||||||
|
|
||||||
|
**Einmalig beim ersten Deploy**:
|
||||||
|
- Setze `PRISMA_AUTO_BASELINE=true` (z.B. als Compose env oder Gitea Variable/Secret)
|
||||||
|
- Deploy ausführen
|
||||||
|
- Danach wieder auf `false` setzen
|
||||||
|
|
||||||
|
Alternative (manuell/sauber):
|
||||||
|
- Baseline per `prisma migrate resolve --applied <init_migration_name>` ausführen (z.B. lokal gegen die Prod-DB)
|
||||||
|
|
||||||
|
|||||||
27
e2e/consent.spec.ts
Normal file
27
e2e/consent.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Consent banner", () => {
|
||||||
|
test("banner shows and can be accepted", async ({ page, context }) => {
|
||||||
|
// Start clean
|
||||||
|
await context.clearCookies();
|
||||||
|
|
||||||
|
await page.goto("/en", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
// Banner should appear on public pages when no consent is set yet
|
||||||
|
const bannerTitle = page.getByText(/Privacy settings|Datenschutz-Einstellungen/i);
|
||||||
|
await expect(bannerTitle).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Accept all
|
||||||
|
const acceptAll = page.getByRole("button", { name: /Accept all|Alles akzeptieren/i });
|
||||||
|
await acceptAll.click();
|
||||||
|
|
||||||
|
// Banner disappears
|
||||||
|
await expect(bannerTitle).toBeHidden({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Cookie is written
|
||||||
|
const cookies = await context.cookies();
|
||||||
|
const consentCookie = cookies.find((c) => c.name === "dk0_consent_v1");
|
||||||
|
expect(consentCookie).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import { test, expect } from '@playwright/test';
|
|||||||
*/
|
*/
|
||||||
test.describe('Critical Paths', () => {
|
test.describe('Critical Paths', () => {
|
||||||
test('Home page loads and displays correctly', async ({ page }) => {
|
test('Home page loads and displays correctly', async ({ page }) => {
|
||||||
await page.goto('/', { waitUntil: 'networkidle' });
|
await page.goto('/en', { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
// Wait for page to be fully loaded
|
// Wait for page to be fully loaded
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('domcontentloaded');
|
||||||
@@ -25,7 +25,7 @@ test.describe('Critical Paths', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Projects page loads and displays projects', async ({ page }) => {
|
test('Projects page loads and displays projects', async ({ page }) => {
|
||||||
await page.goto('/projects', { waitUntil: 'networkidle' });
|
await page.goto('/en/projects', { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
// Wait for projects to load
|
// Wait for projects to load
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('domcontentloaded');
|
||||||
@@ -45,7 +45,7 @@ test.describe('Critical Paths', () => {
|
|||||||
|
|
||||||
test('Individual project page loads', async ({ page }) => {
|
test('Individual project page loads', async ({ page }) => {
|
||||||
// First, get a project slug from the projects page
|
// First, get a project slug from the projects page
|
||||||
await page.goto('/projects', { waitUntil: 'networkidle' });
|
await page.goto('/en/projects', { waitUntil: 'networkidle' });
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
// Try to find a project link
|
// Try to find a project link
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ test.describe('Hydration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Navigate to home page
|
// Navigate to home page
|
||||||
await page.goto('/', { waitUntil: 'networkidle' });
|
await page.goto('/en', { waitUntil: 'networkidle' });
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
// Check for hydration errors
|
// Check for hydration errors
|
||||||
@@ -51,7 +51,7 @@ test.describe('Hydration Tests', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Check for duplicate key warnings
|
// Check for duplicate key warnings
|
||||||
@@ -71,11 +71,11 @@ test.describe('Hydration Tests', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/', { waitUntil: 'networkidle' });
|
await page.goto('/en', { waitUntil: 'networkidle' });
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
// Navigate to projects page via link
|
// Navigate to projects page via link
|
||||||
const projectsLink = page.locator('a[href="/projects"], a[href*="projects"]').first();
|
const projectsLink = page.locator('a[href*="/projects"]').first();
|
||||||
if (await projectsLink.count() > 0) {
|
if (await projectsLink.count() > 0) {
|
||||||
await projectsLink.click();
|
await projectsLink.click();
|
||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('domcontentloaded');
|
||||||
@@ -90,7 +90,7 @@ test.describe('Hydration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Server and client HTML match', async ({ page }) => {
|
test('Server and client HTML match', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
|
|
||||||
// Get initial HTML
|
// Get initial HTML
|
||||||
const initialHTML = await page.content();
|
const initialHTML = await page.content();
|
||||||
@@ -108,7 +108,7 @@ test.describe('Hydration Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('Interactive elements work after hydration', async ({ page }) => {
|
test('Interactive elements work after hydration', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/en');
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
// Try to find and click interactive elements
|
// Try to find and click interactive elements
|
||||||
|
|||||||
17
e2e/i18n.spec.ts
Normal file
17
e2e/i18n.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("i18n routing", () => {
|
||||||
|
test("language switcher navigates between locales", async ({ page }) => {
|
||||||
|
await page.goto("/en", { waitUntil: "domcontentloaded" });
|
||||||
|
|
||||||
|
// Buttons are "EN"/"DE" in the header
|
||||||
|
const deButton = page.getByRole("button", { name: "DE" });
|
||||||
|
if (await deButton.count()) {
|
||||||
|
await deButton.click();
|
||||||
|
await expect(page).toHaveURL(/\/de(\/|$)/);
|
||||||
|
} else {
|
||||||
|
test.skip();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
22
e2e/seo.spec.ts
Normal file
22
e2e/seo.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("SEO endpoints", () => {
|
||||||
|
test("robots.txt is served and contains sitemap", async ({ request }) => {
|
||||||
|
const res = await request.get("/robots.txt");
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const txt = await res.text();
|
||||||
|
expect(txt).toContain("User-agent:");
|
||||||
|
expect(txt).toContain("Sitemap:");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sitemap.xml is served and contains locale routes", async ({ request }) => {
|
||||||
|
const res = await request.get("/sitemap.xml");
|
||||||
|
expect(res.ok()).toBeTruthy();
|
||||||
|
const xml = await res.text();
|
||||||
|
expect(xml).toContain('<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">');
|
||||||
|
// At least the localized home routes should exist
|
||||||
|
expect(xml).toMatch(/\/en<\/loc>/);
|
||||||
|
expect(xml).toMatch(/\/de<\/loc>/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -34,6 +34,14 @@ N8N_API_KEY=your-n8n-api-key
|
|||||||
# JWT_SECRET=your-jwt-secret
|
# JWT_SECRET=your-jwt-secret
|
||||||
# ENCRYPTION_KEY=your-encryption-key
|
# ENCRYPTION_KEY=your-encryption-key
|
||||||
ADMIN_BASIC_AUTH=admin:your_secure_password_here
|
ADMIN_BASIC_AUTH=admin:your_secure_password_here
|
||||||
|
ADMIN_SESSION_SECRET=change_me_to_a_long_random_string_at_least_32_chars
|
||||||
|
|
||||||
|
# Prisma migrations at container startup
|
||||||
|
# - default: migrations are executed (`prisma migrate deploy`)
|
||||||
|
# - set to true ONCE if you already have an existing DB that was created before migrations existed
|
||||||
|
PRISMA_AUTO_BASELINE=false
|
||||||
|
# emergency switch (not recommended for normal operation)
|
||||||
|
# SKIP_PRISMA_MIGRATE=true
|
||||||
|
|
||||||
# Monitoring (optional)
|
# Monitoring (optional)
|
||||||
# SENTRY_DSN=your-sentry-dsn
|
# SENTRY_DSN=your-sentry-dsn
|
||||||
|
|||||||
3
i18n/locales.ts
Normal file
3
i18n/locales.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export const locales = ["en", "de"] as const;
|
||||||
|
export type AppLocale = (typeof locales)[number];
|
||||||
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { getRequestConfig } from 'next-intl/server';
|
import { getRequestConfig } from "next-intl/server";
|
||||||
|
import { locales } from "./locales";
|
||||||
export const locales = ['en', 'de'] as const;
|
export { locales, type AppLocale } from "./locales";
|
||||||
export type AppLocale = (typeof locales)[number];
|
|
||||||
|
|
||||||
export default getRequestConfig(async ({ locale }) => {
|
export default getRequestConfig(async ({ locale }) => {
|
||||||
// next-intl can call us with unknown/undefined locales; fall back safely
|
// next-intl can call us with unknown/undefined locales; fall back safely
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
export async function getSiteSettings() {
|
export async function getSiteSettings() {
|
||||||
return prisma.siteSettings.findUnique({ where: { id: 1 } });
|
return prisma.siteSettings.findUnique({ where: { id: 1 } });
|
||||||
@@ -55,14 +56,14 @@ export async function upsertContentByKey(opts: {
|
|||||||
locale,
|
locale,
|
||||||
title: title ?? undefined,
|
title: title ?? undefined,
|
||||||
slug: slug ?? undefined,
|
slug: slug ?? undefined,
|
||||||
content: content as any, // JSON
|
content: content as Prisma.InputJsonValue, // JSON
|
||||||
metaDescription: metaDescription ?? undefined,
|
metaDescription: metaDescription ?? undefined,
|
||||||
keywords: keywords ?? undefined,
|
keywords: keywords ?? undefined,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
title: title ?? undefined,
|
title: title ?? undefined,
|
||||||
slug: slug ?? undefined,
|
slug: slug ?? undefined,
|
||||||
content: content as any, // JSON
|
content: content as Prisma.InputJsonValue, // JSON
|
||||||
metaDescription: metaDescription ?? undefined,
|
metaDescription: metaDescription ?? undefined,
|
||||||
keywords: keywords ?? undefined,
|
keywords: keywords ?? undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
30
lib/seo.ts
Normal file
30
lib/seo.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { locales, type AppLocale } from "@/i18n/locales";
|
||||||
|
|
||||||
|
export function getBaseUrl(): string {
|
||||||
|
const raw =
|
||||||
|
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||||
|
process.env.NEXTAUTH_URL || // fallback if ever added
|
||||||
|
"http://localhost:3000";
|
||||||
|
return raw.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAbsoluteUrl(path: string): string {
|
||||||
|
const base = getBaseUrl();
|
||||||
|
const normalized = path.startsWith("/") ? path : `/${path}`;
|
||||||
|
return `${base}${normalized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLanguageAlternates(opts: {
|
||||||
|
/** Path without locale prefix, e.g. "/projects" or "/projects/my-slug" or "" */
|
||||||
|
pathWithoutLocale: string;
|
||||||
|
}): Record<AppLocale, string> {
|
||||||
|
const path = opts.pathWithoutLocale === "" ? "" : `/${opts.pathWithoutLocale}`.replace(/\/{2,}/g, "/");
|
||||||
|
const normalizedPath = path === "/" ? "" : path;
|
||||||
|
|
||||||
|
return locales.reduce((acc, l) => {
|
||||||
|
const url = toAbsoluteUrl(`/${l}${normalizedPath}`);
|
||||||
|
acc[l] = url;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<AppLocale, string>);
|
||||||
|
}
|
||||||
|
|
||||||
70
lib/sitemap.ts
Normal file
70
lib/sitemap.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { locales } from "@/i18n/locales";
|
||||||
|
import { getBaseUrl } from "@/lib/seo";
|
||||||
|
|
||||||
|
export type SitemapEntry = {
|
||||||
|
url: string;
|
||||||
|
lastModified: string;
|
||||||
|
changefreq?: "daily" | "weekly" | "monthly" | "yearly";
|
||||||
|
priority?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateSitemapXml(entries: SitemapEntry[]): string {
|
||||||
|
const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>';
|
||||||
|
const urlsetOpen = '<urlset xmlns="https://www.sitemaps.org/schemas/sitemap/0.9">';
|
||||||
|
const urlsetClose = "</urlset>";
|
||||||
|
|
||||||
|
const urlEntries = entries
|
||||||
|
.map((e) => {
|
||||||
|
const changefreq = e.changefreq ?? "monthly";
|
||||||
|
const priority = typeof e.priority === "number" ? e.priority : 0.8;
|
||||||
|
return `
|
||||||
|
<url>
|
||||||
|
<loc>${e.url}</loc>
|
||||||
|
<lastmod>${e.lastModified}</lastmod>
|
||||||
|
<changefreq>${changefreq}</changefreq>
|
||||||
|
<priority>${priority.toFixed(1)}</priority>
|
||||||
|
</url>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `${xmlHeader}${urlsetOpen}${urlEntries}${urlsetClose}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSitemapEntries(): Promise<SitemapEntry[]> {
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
|
||||||
|
const staticPaths = ["", "/projects", "/legal-notice", "/privacy-policy"];
|
||||||
|
const staticEntries: SitemapEntry[] = locales.flatMap((locale) =>
|
||||||
|
staticPaths.map((p) => {
|
||||||
|
const path = p === "" ? `/${locale}` : `/${locale}${p}`;
|
||||||
|
return {
|
||||||
|
url: `${baseUrl}${path}`,
|
||||||
|
lastModified: nowIso,
|
||||||
|
changefreq: p === "" ? "weekly" : p === "/projects" ? "weekly" : "yearly",
|
||||||
|
priority: p === "" ? 1.0 : p === "/projects" ? 0.8 : 0.5,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Projects: for each project slug we publish per locale (same slug)
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { published: true },
|
||||||
|
select: { slug: true, updatedAt: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectEntries: SitemapEntry[] = projects.flatMap((p) => {
|
||||||
|
const lastModified = (p.updatedAt ?? new Date()).toISOString();
|
||||||
|
return locales.map((locale) => ({
|
||||||
|
url: `${baseUrl}/${locale}/projects/${p.slug}`,
|
||||||
|
lastModified,
|
||||||
|
changefreq: "monthly",
|
||||||
|
priority: 0.7,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...staticEntries, ...projectEntries];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
Disallow: /legal-notice
|
|
||||||
Disallow: /privacy-policy
|
|
||||||
Sitemap: https://dki.one/sitemap.xml
|
|
||||||
@@ -80,7 +80,7 @@ echo -e "${YELLOW}[4/5] Verifying critical files...${NC}"
|
|||||||
REQUIRED_FILES=(
|
REQUIRED_FILES=(
|
||||||
"CHANGELOG_DEV.md"
|
"CHANGELOG_DEV.md"
|
||||||
"AFTER_PUSH_SETUP.md"
|
"AFTER_PUSH_SETUP.md"
|
||||||
"prisma/migrations/create_activity_status.sql"
|
"prisma/migrations/migration_lock.toml"
|
||||||
"docs/ai-image-generation/README.md"
|
"docs/ai-image-generation/README.md"
|
||||||
)
|
)
|
||||||
MISSING=0
|
MISSING=0
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
/**
|
/**
|
||||||
* Container entrypoint: apply Prisma migrations, then start Next server.
|
* Container entrypoint: apply Prisma migrations, then start Next server.
|
||||||
*
|
*
|
||||||
@@ -9,6 +10,8 @@
|
|||||||
* - Set `SKIP_PRISMA_MIGRATE=true` to skip migrations (emergency / debugging).
|
* - Set `SKIP_PRISMA_MIGRATE=true` to skip migrations (emergency / debugging).
|
||||||
*/
|
*/
|
||||||
const { spawnSync } = require("node:child_process");
|
const { spawnSync } = require("node:child_process");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
function run(cmd, args, opts = {}) {
|
function run(cmd, args, opts = {}) {
|
||||||
const res = spawnSync(cmd, args, {
|
const res = spawnSync(cmd, args, {
|
||||||
@@ -28,11 +31,36 @@ function run(cmd, args, opts = {}) {
|
|||||||
|
|
||||||
const skip = String(process.env.SKIP_PRISMA_MIGRATE || "").toLowerCase() === "true";
|
const skip = String(process.env.SKIP_PRISMA_MIGRATE || "").toLowerCase() === "true";
|
||||||
if (!skip) {
|
if (!skip) {
|
||||||
|
const autoBaseline =
|
||||||
|
String(process.env.PRISMA_AUTO_BASELINE || "").toLowerCase() === "true";
|
||||||
|
|
||||||
// Avoid relying on `npx` resolution in minimal runtimes.
|
// Avoid relying on `npx` resolution in minimal runtimes.
|
||||||
// We copy `node_modules/prisma` into the runtime image.
|
// We copy `node_modules/prisma` into the runtime image.
|
||||||
|
if (autoBaseline) {
|
||||||
|
try {
|
||||||
|
const migrationsDir = path.join(process.cwd(), "prisma", "migrations");
|
||||||
|
const entries = fs
|
||||||
|
.readdirSync(migrationsDir, { withFileTypes: true })
|
||||||
|
.filter((d) => d.isDirectory())
|
||||||
|
.map((d) => d.name);
|
||||||
|
const initMigration = entries.find((n) => n.endsWith("_init"));
|
||||||
|
if (initMigration) {
|
||||||
|
// This is the documented "baseline" flow for existing databases:
|
||||||
|
// mark the initial migration as already applied.
|
||||||
|
run("node", [
|
||||||
|
"node_modules/prisma/build/index.js",
|
||||||
|
"migrate",
|
||||||
|
"resolve",
|
||||||
|
"--applied",
|
||||||
|
initMigration,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (_err) {
|
||||||
|
// If baseline fails we continue to migrate deploy, which will surface the real issue.
|
||||||
|
}
|
||||||
|
}
|
||||||
run("node", ["node_modules/prisma/build/index.js", "migrate", "deploy"]);
|
run("node", ["node_modules/prisma/build/index.js", "migrate", "deploy"]);
|
||||||
} else {
|
} else {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.log("SKIP_PRISMA_MIGRATE=true -> skipping prisma migrate deploy");
|
console.log("SKIP_PRISMA_MIGRATE=true -> skipping prisma migrate deploy");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"status": "interrupted",
|
|
||||||
"failedTests": []
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user