feat: payload cms
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 1m13s
Build & Deploy / 🏗️ Build (push) Failing after 5m53s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / ⚡ Lighthouse (push) Has been skipped
Build & Deploy / ♿ WCAG (push) Has been skipped
Build & Deploy / 🛡️ Quality Gates (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 1m13s
Build & Deploy / 🏗️ Build (push) Failing after 5m53s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Smoke Test (push) Has been skipped
Build & Deploy / ⚡ Lighthouse (push) Has been skipped
Build & Deploy / ♿ WCAG (push) Has been skipped
Build & Deploy / 🛡️ Quality Gates (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 4s
This commit is contained in:
138
lib/blog.ts
138
lib/blog.ts
@@ -1,7 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { mapSlugToFileSlug } from './slugs';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { config } from '@/lib/config';
|
||||
|
||||
export function extractExcerpt(content: string): string {
|
||||
@@ -42,7 +40,7 @@ export interface PostFrontmatter {
|
||||
export interface PostMdx {
|
||||
slug: string;
|
||||
frontmatter: PostFrontmatter;
|
||||
content: string;
|
||||
content: any; // Mapped to Lexical SerializedEditorState
|
||||
}
|
||||
|
||||
export function isPostVisible(post: { frontmatter: { date: string; public?: boolean } }) {
|
||||
@@ -57,87 +55,81 @@ export function isPostVisible(post: { frontmatter: { date: string; public?: bool
|
||||
}
|
||||
|
||||
export async function getPostBySlug(slug: string, locale: string): Promise<PostMdx | null> {
|
||||
// Map translated slug to file slug
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
||||
const filePath = path.join(postsDir, `${fileSlug}.mdx`);
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
locale: { equals: locale },
|
||||
},
|
||||
draft: process.env.NODE_ENV === 'development',
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
if (!docs || docs.length === 0) return null;
|
||||
|
||||
const postInfo = {
|
||||
slug: fileSlug,
|
||||
const doc = docs[0];
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
...data,
|
||||
excerpt: data.excerpt || extractExcerpt(content),
|
||||
title: doc.title,
|
||||
date: doc.date,
|
||||
excerpt: doc.excerpt || '',
|
||||
category: doc.category || '',
|
||||
locale: doc.locale,
|
||||
featuredImage:
|
||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
||||
: null,
|
||||
public: doc._status === 'published',
|
||||
} as PostFrontmatter,
|
||||
content,
|
||||
content: doc.content as any, // Native Lexical Editor State
|
||||
};
|
||||
|
||||
if (!isPostVisible(postInfo)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return postInfo;
|
||||
}
|
||||
|
||||
export async function getAllPosts(locale: string): Promise<PostMdx[]> {
|
||||
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
||||
if (!fs.existsSync(postsDir)) return [];
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
// Query only published posts (access checks applied automatically by Payload!)
|
||||
const { docs } = await payload.find({
|
||||
collection: 'posts',
|
||||
where: {
|
||||
locale: {
|
||||
equals: locale,
|
||||
},
|
||||
},
|
||||
sort: '-date',
|
||||
draft: process.env.NODE_ENV === 'development', // Includes Drafts if running locally
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const files = fs.readdirSync(postsDir);
|
||||
const posts = files
|
||||
.filter((file) => file.endsWith('.mdx'))
|
||||
.map((file) => {
|
||||
const filePath = path.join(postsDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
return {
|
||||
slug: file.replace(/\.mdx$/, ''),
|
||||
frontmatter: {
|
||||
...data,
|
||||
excerpt: data.excerpt || extractExcerpt(content),
|
||||
} as PostFrontmatter,
|
||||
content,
|
||||
};
|
||||
})
|
||||
.filter(isPostVisible)
|
||||
.sort(
|
||||
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||
);
|
||||
|
||||
return posts;
|
||||
return docs.map((doc) => {
|
||||
return {
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
title: doc.title,
|
||||
date: doc.date,
|
||||
excerpt: doc.excerpt || '',
|
||||
category: doc.category || '',
|
||||
locale: doc.locale,
|
||||
featuredImage:
|
||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
||||
: null,
|
||||
} as PostFrontmatter,
|
||||
// Pass the Lexical content object rather than raw markdown string
|
||||
content: doc.content as any,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllPostsMetadata(locale: string): Promise<Partial<PostMdx>[]> {
|
||||
const postsDir = path.join(process.cwd(), 'data', 'blog', locale);
|
||||
if (!fs.existsSync(postsDir)) return [];
|
||||
|
||||
const files = fs.readdirSync(postsDir);
|
||||
return files
|
||||
.filter((file) => file.endsWith('.mdx'))
|
||||
.map((file) => {
|
||||
const filePath = path.join(postsDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data } = matter(fileContent);
|
||||
return {
|
||||
slug: file.replace(/\.mdx$/, ''),
|
||||
frontmatter: {
|
||||
...data,
|
||||
excerpt: data.excerpt || extractExcerpt(fileContent.replace(/^---[\s\S]*?---/, '')),
|
||||
} as PostFrontmatter,
|
||||
};
|
||||
})
|
||||
.filter(isPostVisible)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.frontmatter.date as string).getTime() -
|
||||
new Date(a.frontmatter.date as string).getTime(),
|
||||
);
|
||||
const posts = await getAllPosts(locale);
|
||||
return posts.map((p) => ({
|
||||
slug: p.slug,
|
||||
frontmatter: p.frontmatter,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAdjacentPosts(
|
||||
|
||||
@@ -13,7 +13,10 @@ let memoizedConfig: ReturnType<typeof createConfig> | undefined;
|
||||
function createConfig() {
|
||||
const env = getRawEnv();
|
||||
|
||||
const target = env.NEXT_PUBLIC_TARGET || env.TARGET;
|
||||
const target =
|
||||
env.NEXT_PUBLIC_TARGET ||
|
||||
env.TARGET ||
|
||||
(env.NODE_ENV === 'development' ? 'development' : 'production');
|
||||
|
||||
console.log('[Config] Initializing Toggles:', {
|
||||
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
|
||||
@@ -65,17 +68,9 @@ function createConfig() {
|
||||
from: env.MAIL_FROM,
|
||||
recipients: env.MAIL_RECIPIENTS,
|
||||
},
|
||||
directus: {
|
||||
url: env.DIRECTUS_URL,
|
||||
adminEmail: env.DIRECTUS_ADMIN_EMAIL,
|
||||
password: env.DIRECTUS_ADMIN_PASSWORD,
|
||||
token: env.DIRECTUS_API_TOKEN,
|
||||
internalUrl: env.INTERNAL_DIRECTUS_URL,
|
||||
proxyPath: '/cms',
|
||||
},
|
||||
infraCMS: {
|
||||
url: env.INFRA_DIRECTUS_URL || env.DIRECTUS_URL,
|
||||
token: env.INFRA_DIRECTUS_TOKEN || env.DIRECTUS_API_TOKEN,
|
||||
url: env.INFRA_DIRECTUS_URL,
|
||||
token: env.INFRA_DIRECTUS_TOKEN,
|
||||
},
|
||||
notifications: {
|
||||
gotify: {
|
||||
@@ -139,9 +134,6 @@ export const config = {
|
||||
get mail() {
|
||||
return getConfig().mail;
|
||||
},
|
||||
get directus() {
|
||||
return getConfig().directus;
|
||||
},
|
||||
get notifications() {
|
||||
return getConfig().notifications;
|
||||
},
|
||||
@@ -192,12 +184,6 @@ export function getMaskedConfig() {
|
||||
from: c.mail.from,
|
||||
recipients: c.mail.recipients,
|
||||
},
|
||||
directus: {
|
||||
url: c.directus.url,
|
||||
adminEmail: mask(c.directus.adminEmail),
|
||||
password: mask(c.directus.password),
|
||||
token: mask(c.directus.token),
|
||||
},
|
||||
notifications: {
|
||||
gotify: {
|
||||
url: c.notifications.gotify.url,
|
||||
|
||||
192
lib/directus.ts
192
lib/directus.ts
@@ -1,192 +0,0 @@
|
||||
import { readItems, readCollections } from '@directus/sdk';
|
||||
import { createMintelDirectusClient, ensureDirectusAuthenticated } from '@mintel/next-utils';
|
||||
import { config } from './config';
|
||||
import { getServerAppServices } from './services/create-services.server';
|
||||
|
||||
/**
|
||||
* Directus Schema Definitions
|
||||
*/
|
||||
export interface Schema {
|
||||
products: any[];
|
||||
categories: any[];
|
||||
contact_submissions: any[];
|
||||
product_requests: any[];
|
||||
translations: any[];
|
||||
categories_link: any[];
|
||||
}
|
||||
|
||||
// Initialize client using Mintel standards (environment-aware)
|
||||
const client = createMintelDirectusClient<Schema>();
|
||||
|
||||
/**
|
||||
* Helper to determine if we should show detailed errors
|
||||
*/
|
||||
const shouldShowDevErrors = config.isTesting || config.isDevelopment;
|
||||
|
||||
/**
|
||||
* Genericizes error messages for production/staging
|
||||
*/
|
||||
function formatError(error: any) {
|
||||
if (shouldShowDevErrors) {
|
||||
return error.errors?.[0]?.message || error.message || 'An unexpected error occurred.';
|
||||
}
|
||||
return 'A system error occurred. Our team has been notified.';
|
||||
}
|
||||
|
||||
export async function ensureAuthenticated() {
|
||||
try {
|
||||
await ensureDirectusAuthenticated(client);
|
||||
} catch (e: any) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
|
||||
}
|
||||
console.error(`Failed to authenticate with Directus:`, e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the new translation-based schema back to the application's Product interface
|
||||
*/
|
||||
function mapDirectusProduct(item: any, locale: string): any {
|
||||
const langCode = locale === 'en' ? 'en-US' : 'de-DE';
|
||||
const translation =
|
||||
item.translations?.find((t: any) => t.languages_code === langCode) ||
|
||||
item.translations?.[0] ||
|
||||
{};
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
sku: item.sku,
|
||||
title: translation.name || '',
|
||||
description: translation.description || '',
|
||||
content: translation.content || '',
|
||||
technicalData: {
|
||||
technicalItems: translation.technical_items || [],
|
||||
voltageTables: translation.voltage_tables || [],
|
||||
},
|
||||
locale: locale,
|
||||
// Use standardized proxy path for assets to avoid CORS
|
||||
data_sheet_url: item.data_sheet ? `/api/directus/assets/${item.data_sheet}` : null,
|
||||
categories: (item.categories_link || [])
|
||||
.map((c: any) => c.categories_id?.translations?.[0]?.name)
|
||||
.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProducts(locale: string = 'de') {
|
||||
await ensureAuthenticated();
|
||||
try {
|
||||
const items = await client.request(
|
||||
readItems('products', {
|
||||
fields: ['*', 'translations.*', 'categories_link.categories_id.translations.name'],
|
||||
}),
|
||||
);
|
||||
return items.map((item) => mapDirectusProduct(item, locale));
|
||||
} catch (error) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(error, { part: 'directus_get_products' });
|
||||
}
|
||||
console.error('Error fetching products:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProductBySlug(slug: string, locale: string = 'de') {
|
||||
await ensureAuthenticated();
|
||||
const langCode = locale === 'en' ? 'en-US' : 'de-DE';
|
||||
try {
|
||||
const items = await client.request(
|
||||
readItems('products', {
|
||||
filter: {
|
||||
translations: {
|
||||
slug: { _eq: slug },
|
||||
languages_code: { _eq: langCode },
|
||||
},
|
||||
},
|
||||
fields: ['*', 'translations.*', 'categories_link.categories_id.translations.name'],
|
||||
limit: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!items || items.length === 0) return null;
|
||||
return mapDirectusProduct(items[0], locale);
|
||||
} catch (error) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(error, {
|
||||
part: 'directus_get_product_by_slug',
|
||||
slug,
|
||||
});
|
||||
}
|
||||
console.error(`Error fetching product ${slug}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkHealth() {
|
||||
try {
|
||||
// 1. Connectivity & Auth Check
|
||||
try {
|
||||
await ensureAuthenticated();
|
||||
await client.request(readCollections());
|
||||
} catch (e: any) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(e, { part: 'directus_health_auth' });
|
||||
}
|
||||
console.error('Directus authentication or collection-read failed during health check:', e);
|
||||
return {
|
||||
status: 'error',
|
||||
message: shouldShowDevErrors
|
||||
? `Directus Health Error: ${e.message || 'Unknown'}`
|
||||
: 'CMS is currently unavailable due to an internal authentication or connection error.',
|
||||
code: e.code || 'HEALTH_AUTH_FAILED',
|
||||
details: shouldShowDevErrors
|
||||
? { message: e.message, code: e.code, errors: e.errors }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Schema check (does the contact_submissions table exist?)
|
||||
try {
|
||||
await client.request(readItems('contact_submissions', { limit: 1 }));
|
||||
} catch (e: any) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(e, { part: 'directus_health_schema' });
|
||||
}
|
||||
if (
|
||||
e.message?.includes('does not exist') ||
|
||||
e.code === 'INVALID_PAYLOAD' ||
|
||||
e.status === 404
|
||||
) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: shouldShowDevErrors
|
||||
? `The "contact_submissions" collection is missing or inaccessible. Error: ${e.message || 'Unknown'}`
|
||||
: 'Required data structures are currently unavailable.',
|
||||
code: 'SCHEMA_MISSING',
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'error',
|
||||
message: shouldShowDevErrors
|
||||
? `Schema error: ${e.errors?.[0]?.message || e.message || 'Unknown error'}`
|
||||
: 'The data schema is currently misconfigured.',
|
||||
code: 'SCHEMA_ERROR',
|
||||
};
|
||||
}
|
||||
|
||||
return { status: 'ok', message: 'Directus is reachable and responding.' };
|
||||
} catch (error: any) {
|
||||
if (typeof window === 'undefined') {
|
||||
getServerAppServices().errors.captureException(error, { part: 'directus_health_critical' });
|
||||
}
|
||||
console.error('Directus health check failed with unexpected error:', error);
|
||||
return {
|
||||
status: 'error',
|
||||
message: formatError(error),
|
||||
code: error.code || 'UNKNOWN',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default client;
|
||||
@@ -43,12 +43,6 @@ const envExtension = {
|
||||
MAIL_PASSWORD: z.string().optional(),
|
||||
MAIL_FROM: z.string().optional(),
|
||||
MAIL_RECIPIENTS: z.string().optional(),
|
||||
|
||||
// Directus Authentication
|
||||
DIRECTUS_URL: z.string().url().optional(),
|
||||
DIRECTUS_ADMIN_EMAIL: z.string().email().optional(),
|
||||
DIRECTUS_ADMIN_PASSWORD: z.string().optional(),
|
||||
DIRECTUS_API_TOKEN: z.string().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { getImgproxyUrl } from './imgproxy';
|
||||
|
||||
/**
|
||||
* Next.js Image Loader for imgproxy
|
||||
*
|
||||
* @param {Object} props - properties from Next.js Image component
|
||||
* @param {string} props.src - The source image URL
|
||||
* @param {number} props.width - The desired image width
|
||||
* @param {number} props.quality - The desired image quality (ignored for now as imgproxy handles it)
|
||||
*/
|
||||
export default function imgproxyLoader({
|
||||
src,
|
||||
width,
|
||||
_quality,
|
||||
}: {
|
||||
src: string;
|
||||
width: number;
|
||||
_quality?: number;
|
||||
}) {
|
||||
// Skip imgproxy for SVGs as they are vectors and don't benefit from resizing,
|
||||
// and often cause 404s if the source is not correctly resolvable by imgproxy.
|
||||
if (src.toLowerCase().endsWith('.svg')) {
|
||||
return src;
|
||||
}
|
||||
|
||||
// Check if src contains custom gravity or aspect ratio query parameters
|
||||
let gravity = 'sm'; // Use smart gravity (content-aware) by default
|
||||
let cleanSrc = src;
|
||||
let calculatedHeight = 0;
|
||||
let resizingType: 'fit' | 'fill' = 'fit';
|
||||
|
||||
try {
|
||||
// Dummy base needed for relative URLs
|
||||
const url = new URL(src, 'http://localhost');
|
||||
const customGravity = url.searchParams.get('gravity');
|
||||
const aspectRatio = url.searchParams.get('ar'); // e.g. "16:9"
|
||||
|
||||
if (customGravity) {
|
||||
gravity = customGravity;
|
||||
url.searchParams.delete('gravity');
|
||||
}
|
||||
|
||||
if (aspectRatio) {
|
||||
const parts = aspectRatio.split(':');
|
||||
if (parts.length === 2) {
|
||||
const arW = parseFloat(parts[0]);
|
||||
const arH = parseFloat(parts[1]);
|
||||
if (!isNaN(arW) && !isNaN(arH) && arW > 0) {
|
||||
calculatedHeight = Math.round(width * (arH / arW));
|
||||
resizingType = 'fill'; // Must use fill to allow imgproxy to crop
|
||||
}
|
||||
}
|
||||
url.searchParams.delete('ar');
|
||||
}
|
||||
|
||||
if (customGravity || aspectRatio) {
|
||||
cleanSrc = src.startsWith('http') ? url.href : url.pathname + url.search;
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback if parsing fails
|
||||
}
|
||||
|
||||
// We use the width provided by Next.js for responsive images
|
||||
// Height is calculated from aspect ratio if provided, otherwise 0 to maintain aspect ratio
|
||||
return getImgproxyUrl(cleanSrc, {
|
||||
width,
|
||||
height: calculatedHeight,
|
||||
resizing_type: resizingType,
|
||||
gravity,
|
||||
});
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* Generates an imgproxy URL for a given source image and options.
|
||||
*
|
||||
* Documentation: https://docs.imgproxy.net/usage/processing
|
||||
*/
|
||||
|
||||
interface ImgproxyOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
resizing_type?: 'fit' | 'fill' | 'fill-down' | 'force' | 'auto';
|
||||
gravity?: string;
|
||||
enlarge?: boolean;
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export function getImgproxyUrl(src: string, options: ImgproxyOptions = {}): string {
|
||||
// Use local proxy path which is rewritten in next.config.mjs
|
||||
const baseUrl = '/_img';
|
||||
|
||||
// Handle local paths or relative URLs
|
||||
let absoluteSrc = src;
|
||||
if (src.startsWith('/')) {
|
||||
const baseUrlForSrc =
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : 'https://klz-cables.com');
|
||||
if (baseUrlForSrc) {
|
||||
absoluteSrc = `${baseUrlForSrc.replace(/\/$/, '')}${src}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Development mapping: Map local domains to internal Docker hostnames
|
||||
// so imgproxy can fetch images without SSL issues or external routing
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
if (absoluteSrc.includes('klz.localhost')) {
|
||||
absoluteSrc = absoluteSrc.replace(/^https?:\/\/klz\.localhost/, 'http://klz-app:3000');
|
||||
} else if (absoluteSrc.includes('cms.klz.localhost')) {
|
||||
absoluteSrc = absoluteSrc.replace(/^https?:\/\/cms\.klz\.localhost/, 'http://klz-cms:8055');
|
||||
}
|
||||
// Also handle direct container names if needed
|
||||
}
|
||||
|
||||
const { width = 0, height = 0, enlarge = false, extension = '' } = options;
|
||||
|
||||
let quality = 80;
|
||||
if (extension) quality = 90;
|
||||
|
||||
// Re-map imgproxy URL to our new parameter structure
|
||||
// e.g. /process?url=...&w=...&h=...&q=...&format=...
|
||||
const queryParams = new URLSearchParams({
|
||||
url: absoluteSrc,
|
||||
});
|
||||
|
||||
if (width > 0) queryParams.set('w', width.toString());
|
||||
if (height > 0) queryParams.set('h', height.toString());
|
||||
if (extension) queryParams.set('format', extension.replace('.', ''));
|
||||
if (quality) queryParams.set('q', quality.toString());
|
||||
|
||||
return `${baseUrl}/process?${queryParams.toString()}`;
|
||||
}
|
||||
285
lib/mdx.ts
285
lib/mdx.ts
@@ -1,6 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
import { mapSlugToFileSlug } from './slugs';
|
||||
|
||||
export interface ProductFrontmatter {
|
||||
@@ -10,65 +9,69 @@ export interface ProductFrontmatter {
|
||||
categories: string[];
|
||||
images: string[];
|
||||
locale: string;
|
||||
isFallback?: boolean;
|
||||
}
|
||||
|
||||
export interface ProductMdx {
|
||||
slug: string;
|
||||
frontmatter: ProductFrontmatter;
|
||||
content: string;
|
||||
content: any; // Lexical AST from Payload
|
||||
}
|
||||
|
||||
export async function getProductMetadata(
|
||||
slug: string,
|
||||
locale: string,
|
||||
): Promise<Partial<ProductMdx> | null> {
|
||||
// Map translated slug to file slug
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||
|
||||
if (!fs.existsSync(productsDir)) return null;
|
||||
let result = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
and: [{ slug: { equals: fileSlug } }, { locale: { equals: locale } }],
|
||||
},
|
||||
depth: 1, // To auto-resolve Media relation (images array)
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
// Recursive search for the file
|
||||
const findFile = (dir: string): string | null => {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
const found = findFile(fullPath);
|
||||
if (found) return found;
|
||||
} else if (file === `${fileSlug}.mdx` || file === `${fileSlug}-2.mdx`) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
let isFallback = false;
|
||||
|
||||
let filePath = findFile(productsDir);
|
||||
|
||||
if (!filePath && locale !== 'en') {
|
||||
if (result.docs.length === 0 && locale !== 'en') {
|
||||
// Fallback to English
|
||||
const enProductsDir = path.join(process.cwd(), 'data', 'products', 'en');
|
||||
if (fs.existsSync(enProductsDir)) {
|
||||
filePath = findFile(enProductsDir);
|
||||
result = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
and: [{ slug: { equals: fileSlug } }, { locale: { equals: 'en' } }],
|
||||
},
|
||||
depth: 1,
|
||||
limit: 1,
|
||||
});
|
||||
if (result.docs.length > 0) {
|
||||
isFallback = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data } = matter(fileContent);
|
||||
if (result.docs.length > 0) {
|
||||
const doc = result.docs[0];
|
||||
|
||||
// Filter out products without images to match getProductBySlug behavior
|
||||
if (!data.images || data.images.length === 0 || !data.images[0]) {
|
||||
return null;
|
||||
}
|
||||
// Process Images
|
||||
const resolvedImages = ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean);
|
||||
|
||||
if (resolvedImages.length === 0) return null; // Original logic skipped docs without images
|
||||
|
||||
return {
|
||||
slug: fileSlug,
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
...data,
|
||||
isFallback: filePath.includes('/en/'),
|
||||
} as any,
|
||||
title: doc.title,
|
||||
sku: doc.sku,
|
||||
description: doc.description,
|
||||
categories: Array.isArray(doc.categories) ? doc.categories.map((c: any) => c.category) : [],
|
||||
images: resolvedImages,
|
||||
locale: doc.locale,
|
||||
isFallback,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,111 +79,159 @@ export async function getProductMetadata(
|
||||
}
|
||||
|
||||
export async function getProductBySlug(slug: string, locale: string): Promise<ProductMdx | null> {
|
||||
// Map translated slug to file slug
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||
|
||||
if (!fs.existsSync(productsDir)) return null;
|
||||
let result = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
and: [{ slug: { equals: fileSlug } }, { locale: { equals: locale } }],
|
||||
},
|
||||
depth: 1, // Auto-resolve Media logic
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
// Recursive search for the file
|
||||
const findFile = (dir: string): string | null => {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
const found = findFile(fullPath);
|
||||
if (found) return found;
|
||||
} else if (file === `${fileSlug}.mdx` || file === `${fileSlug}-2.mdx`) {
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let filePath = findFile(productsDir);
|
||||
let isFallback = false;
|
||||
|
||||
if (!filePath && locale !== 'en') {
|
||||
if (result.docs.length === 0 && locale !== 'en') {
|
||||
// Fallback to English
|
||||
const enProductsDir = path.join(process.cwd(), 'data', 'products', 'en');
|
||||
if (fs.existsSync(enProductsDir)) {
|
||||
filePath = findFile(enProductsDir);
|
||||
if (filePath) isFallback = true;
|
||||
result = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
and: [{ slug: { equals: fileSlug } }, { locale: { equals: 'en' } }],
|
||||
},
|
||||
depth: 1,
|
||||
limit: 1,
|
||||
});
|
||||
if (result.docs.length > 0) {
|
||||
isFallback = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
const product = {
|
||||
slug: fileSlug,
|
||||
if (result.docs.length > 0) {
|
||||
const doc = result.docs[0];
|
||||
|
||||
// Map Images correctly from resolved Media docs
|
||||
const resolvedImages = ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean);
|
||||
|
||||
if (resolvedImages.length === 0) return null;
|
||||
|
||||
return {
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
...data,
|
||||
title: doc.title,
|
||||
sku: doc.sku,
|
||||
description: doc.description,
|
||||
categories: Array.isArray(doc.categories) ? doc.categories.map((c: any) => c.category) : [],
|
||||
images: resolvedImages,
|
||||
locale: doc.locale,
|
||||
isFallback,
|
||||
} as any,
|
||||
content,
|
||||
},
|
||||
content: doc.content, // Lexical payload instead of raw MDX String
|
||||
};
|
||||
|
||||
// Filter out products without images
|
||||
if (
|
||||
!product.frontmatter.images ||
|
||||
product.frontmatter.images.length === 0 ||
|
||||
!product.frontmatter.images[0]
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getAllProductSlugs(locale: string): Promise<string[]> {
|
||||
const productsDir = path.join(process.cwd(), 'data', 'products', locale);
|
||||
if (!fs.existsSync(productsDir)) return [];
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const result = await payload.find({
|
||||
collection: 'products',
|
||||
where: {
|
||||
locale: {
|
||||
equals: locale,
|
||||
},
|
||||
},
|
||||
pagination: false, // get all docs
|
||||
});
|
||||
|
||||
const slugs: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
walk(fullPath);
|
||||
} else if (file.endsWith('.mdx')) {
|
||||
slugs.push(file.replace(/\.mdx$/, ''));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(productsDir);
|
||||
return slugs;
|
||||
return result.docs.map((doc) => doc.slug);
|
||||
}
|
||||
|
||||
export async function getAllProducts(locale: string): Promise<ProductMdx[]> {
|
||||
const slugs = await getAllProductSlugs(locale);
|
||||
let allSlugs = slugs;
|
||||
// Fetch ALL products in a single query to avoid N+1 getPayload() calls
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
// Get products for this locale
|
||||
const result = await payload.find({
|
||||
collection: 'products',
|
||||
where: { locale: { equals: locale } },
|
||||
depth: 1,
|
||||
pagination: false,
|
||||
});
|
||||
|
||||
let products: ProductMdx[] = result.docs
|
||||
.filter((doc) => {
|
||||
const resolvedImages = ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean);
|
||||
return resolvedImages.length > 0;
|
||||
})
|
||||
.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
title: doc.title,
|
||||
sku: doc.sku,
|
||||
description: doc.description,
|
||||
categories: Array.isArray(doc.categories) ? doc.categories.map((c: any) => c.category) : [],
|
||||
images: ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean),
|
||||
locale: doc.locale,
|
||||
},
|
||||
content: doc.content,
|
||||
}));
|
||||
|
||||
// Also include English fallbacks for slugs not in this locale
|
||||
if (locale !== 'en') {
|
||||
const enSlugs = await getAllProductSlugs('en');
|
||||
allSlugs = Array.from(new Set([...slugs, ...enSlugs]));
|
||||
const localeSlugs = new Set(products.map((p) => p.slug));
|
||||
const enResult = await payload.find({
|
||||
collection: 'products',
|
||||
where: { locale: { equals: 'en' } },
|
||||
depth: 1,
|
||||
pagination: false,
|
||||
});
|
||||
|
||||
const fallbacks = enResult.docs
|
||||
.filter((doc) => !localeSlugs.has(doc.slug))
|
||||
.filter((doc) => {
|
||||
const resolvedImages = ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean);
|
||||
return resolvedImages.length > 0;
|
||||
})
|
||||
.map((doc) => ({
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
title: doc.title,
|
||||
sku: doc.sku,
|
||||
description: doc.description,
|
||||
categories: Array.isArray(doc.categories)
|
||||
? doc.categories.map((c: any) => c.category)
|
||||
: [],
|
||||
images: ((doc.images as any[]) || [])
|
||||
.map((img) => (typeof img === 'string' ? img : img.url))
|
||||
.filter(Boolean),
|
||||
locale: doc.locale,
|
||||
isFallback: true,
|
||||
},
|
||||
content: doc.content,
|
||||
}));
|
||||
|
||||
products = [...products, ...fallbacks];
|
||||
}
|
||||
|
||||
const products = await Promise.all(allSlugs.map((slug) => getProductBySlug(slug, locale)));
|
||||
return products.filter((p): p is ProductMdx => p !== null);
|
||||
return products;
|
||||
}
|
||||
|
||||
export async function getAllProductsMetadata(locale: string): Promise<Partial<ProductMdx>[]> {
|
||||
const slugs = await getAllProductSlugs(locale);
|
||||
let allSlugs = slugs;
|
||||
|
||||
if (locale !== 'en') {
|
||||
const enSlugs = await getAllProductSlugs('en');
|
||||
allSlugs = Array.from(new Set([...slugs, ...enSlugs]));
|
||||
}
|
||||
|
||||
const metadata = await Promise.all(allSlugs.map((slug) => getProductMetadata(slug, locale)));
|
||||
return metadata.filter((m): m is Partial<ProductMdx> => m !== null);
|
||||
// Reuse getAllProducts to avoid separate N+1 queries
|
||||
const products = await getAllProducts(locale);
|
||||
return products.map((p) => ({
|
||||
slug: p.slug,
|
||||
frontmatter: p.frontmatter,
|
||||
}));
|
||||
}
|
||||
|
||||
134
lib/pages.ts
134
lib/pages.ts
@@ -1,80 +1,112 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { mapSlugToFileSlug } from './slugs';
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '@payload-config';
|
||||
|
||||
export interface PageFrontmatter {
|
||||
title: string;
|
||||
excerpt: string;
|
||||
featuredImage: string | null;
|
||||
locale: string;
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
export interface PageMdx {
|
||||
slug: string;
|
||||
frontmatter: PageFrontmatter;
|
||||
content: string;
|
||||
content: any; // Lexical AST Document
|
||||
}
|
||||
|
||||
export async function getPageBySlug(slug: string, locale: string): Promise<PageMdx | null> {
|
||||
// Map translated slug to file slug
|
||||
const fileSlug = await mapSlugToFileSlug(slug, locale);
|
||||
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
||||
const filePath = path.join(pagesDir, `${fileSlug}.mdx`);
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
const result = await payload.find({
|
||||
collection: 'pages' as any,
|
||||
where: {
|
||||
slug: { equals: slug },
|
||||
locale: { equals: locale },
|
||||
},
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
const docs = result.docs as any[];
|
||||
|
||||
if (!docs || docs.length === 0) return null;
|
||||
|
||||
const doc = docs[0];
|
||||
|
||||
return {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as PageFrontmatter,
|
||||
content,
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
title: doc.title,
|
||||
excerpt: doc.excerpt || '',
|
||||
locale: doc.locale,
|
||||
featuredImage:
|
||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
||||
: null,
|
||||
} as PageFrontmatter,
|
||||
content: doc.content as any, // Native Lexical Editor State
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAllPages(locale: string): Promise<PageMdx[]> {
|
||||
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
||||
if (!fs.existsSync(pagesDir)) return [];
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
const files = fs.readdirSync(pagesDir);
|
||||
const pages = await Promise.all(
|
||||
files
|
||||
.filter((file) => file.endsWith('.mdx'))
|
||||
.map((file) => {
|
||||
const fileSlug = file.replace(/\.mdx$/, '');
|
||||
const filePath = path.join(pagesDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data, content } = matter(fileContent);
|
||||
return {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as PageFrontmatter,
|
||||
content,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const result = await payload.find({
|
||||
collection: 'pages' as any,
|
||||
where: {
|
||||
locale: {
|
||||
equals: locale,
|
||||
},
|
||||
},
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return pages.filter((p): p is PageMdx => p !== null);
|
||||
const docs = result.docs as any[];
|
||||
|
||||
return docs.map((doc: any) => {
|
||||
return {
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
title: doc.title,
|
||||
excerpt: doc.excerpt || '',
|
||||
locale: doc.locale,
|
||||
featuredImage:
|
||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
||||
: null,
|
||||
} as PageFrontmatter,
|
||||
content: doc.content as any,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllPagesMetadata(locale: string): Promise<Partial<PageMdx>[]> {
|
||||
const pagesDir = path.join(process.cwd(), 'data', 'pages', locale);
|
||||
if (!fs.existsSync(pagesDir)) return [];
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
const files = fs.readdirSync(pagesDir);
|
||||
return files
|
||||
.filter((file) => file.endsWith('.mdx'))
|
||||
.map((file) => {
|
||||
const fileSlug = file.replace(/\.mdx$/, '');
|
||||
const filePath = path.join(pagesDir, file);
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const { data } = matter(fileContent);
|
||||
return {
|
||||
slug: fileSlug,
|
||||
frontmatter: data as PageFrontmatter,
|
||||
};
|
||||
});
|
||||
const result = await payload.find({
|
||||
collection: 'pages' as any,
|
||||
where: {
|
||||
locale: {
|
||||
equals: locale,
|
||||
},
|
||||
},
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const docs = result.docs as any[];
|
||||
|
||||
return docs.map((doc: any) => {
|
||||
return {
|
||||
slug: doc.slug,
|
||||
frontmatter: {
|
||||
title: doc.title,
|
||||
excerpt: doc.excerpt || '',
|
||||
locale: doc.locale,
|
||||
featuredImage:
|
||||
typeof doc.featuredImage === 'object' && doc.featuredImage !== null
|
||||
? doc.featuredImage.sizes?.card?.url || doc.featuredImage.url
|
||||
: null,
|
||||
} as PageFrontmatter,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,9 +11,19 @@ import {
|
||||
import { PinoLoggerService } from './logging/pino-logger-service';
|
||||
import { config, getMaskedConfig } from '../config';
|
||||
|
||||
let singleton: AppServices | undefined;
|
||||
declare global {
|
||||
var __appServices: AppServices | undefined;
|
||||
}
|
||||
|
||||
// Add a local cache to prevent re-looking up globalThis frequently
|
||||
let serverSingleton: AppServices | undefined;
|
||||
|
||||
export function getServerAppServices(): AppServices {
|
||||
if (singleton) return singleton;
|
||||
if (serverSingleton) return serverSingleton;
|
||||
if (globalThis.__appServices) {
|
||||
serverSingleton = globalThis.__appServices;
|
||||
return serverSingleton;
|
||||
}
|
||||
|
||||
// Create logger first to log initialization
|
||||
const logger = new PinoLoggerService('server');
|
||||
@@ -74,9 +84,9 @@ export function getServerAppServices(): AppServices {
|
||||
level: config.logging.level,
|
||||
});
|
||||
|
||||
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||
globalThis.__appServices = new AppServices(analytics, errors, cache, logger, notifications);
|
||||
|
||||
logger.info('All application services initialized successfully');
|
||||
|
||||
return singleton;
|
||||
return globalThis.__appServices;
|
||||
}
|
||||
|
||||
@@ -9,65 +9,18 @@ import { PinoLoggerService } from './logging/pino-logger-service';
|
||||
import { NoopNotificationService } from './notifications/gotify-notification-service';
|
||||
import { config, getMaskedConfig } from '../config';
|
||||
|
||||
/**
|
||||
* Singleton instance of AppServices.
|
||||
*
|
||||
* In Next.js, module singletons are per-process (server) and per-tab (client).
|
||||
* This is sufficient for a small service layer and provides better performance
|
||||
* than creating new instances on every request.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
declare global {
|
||||
var __appServices: AppServices | undefined;
|
||||
}
|
||||
|
||||
let singleton: AppServices | undefined;
|
||||
|
||||
/**
|
||||
* Get the application services singleton.
|
||||
*
|
||||
* This function creates and caches the application services, including:
|
||||
* - Analytics service (Umami or no-op)
|
||||
* - Error reporting service (GlitchTip/Sentry or no-op)
|
||||
* - Cache service (in-memory)
|
||||
*
|
||||
* The services are configured based on environment variables:
|
||||
* - `UMAMI_WEBSITE_ID` - Enables Umami analytics
|
||||
* - `SENTRY_DSN` - Enables error reporting (server-side direct, client-side via relay)
|
||||
*
|
||||
* @returns {AppServices} The application services singleton
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Get services in a client component
|
||||
* import { getAppServices } from '@/lib/services/create-services';
|
||||
*
|
||||
* const services = getAppServices();
|
||||
* services.analytics.track('button_click', { button_id: 'cta' });
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Get services in a server component or API route
|
||||
* import { getAppServices } from '@/lib/services/create-services';
|
||||
*
|
||||
* const services = getAppServices();
|
||||
* await services.cache.set('key', 'value');
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Automatic service selection based on environment
|
||||
* // If UMAMI_WEBSITE_ID is set:
|
||||
* // services.analytics = UmamiAnalyticsService
|
||||
* // If not set:
|
||||
* // services.analytics = NoopAnalyticsService (safe no-op)
|
||||
* ```
|
||||
*
|
||||
* @see {@link UmamiAnalyticsService} for analytics implementation
|
||||
* @see {@link NoopAnalyticsService} for no-op fallback
|
||||
* @see {@link GlitchtipErrorReportingService} for error reporting
|
||||
* @see {@link MemoryCacheService} for caching
|
||||
*/
|
||||
export function getAppServices(): AppServices {
|
||||
// Return cached instance if available
|
||||
if (typeof window === 'undefined' && globalThis.__appServices) return globalThis.__appServices;
|
||||
if (singleton) return singleton;
|
||||
|
||||
// Create logger first to log initialization
|
||||
@@ -127,9 +80,6 @@ export function getAppServices(): AppServices {
|
||||
logger.info('Noop error reporting service initialized (error reporting disabled)');
|
||||
}
|
||||
|
||||
// IMPORTANT: This module is imported by client components.
|
||||
// Do not import Node-only modules (like the `redis` client) here.
|
||||
// Use [`getServerAppServices()`](lib/services/create-services.server.ts:1) on the server.
|
||||
const cache = new MemoryCacheService();
|
||||
logger.info('Memory cache service initialized');
|
||||
|
||||
@@ -139,6 +89,11 @@ export function getAppServices(): AppServices {
|
||||
});
|
||||
|
||||
// Create and cache the singleton
|
||||
if (typeof window === 'undefined') {
|
||||
globalThis.__appServices = new AppServices(analytics, errors, cache, logger, notifications);
|
||||
return globalThis.__appServices;
|
||||
}
|
||||
|
||||
singleton = new AppServices(analytics, errors, cache, logger, notifications);
|
||||
|
||||
logger.info('All application services initialized successfully');
|
||||
|
||||
@@ -97,7 +97,7 @@ export class GlitchtipErrorReportingService implements ErrorReportingService {
|
||||
this.getSentry().then((Sentry) => Sentry.setTag(key, value));
|
||||
}
|
||||
|
||||
withScope<T>(fn: () => T, context?: Record<string, unknown>): T {
|
||||
withScope<T>(fn: () => T, _context?: Record<string, unknown>): T {
|
||||
if (!this.options.enabled) return fn();
|
||||
|
||||
// Since withScope mandates executing fn() synchronously to return T,
|
||||
|
||||
Reference in New Issue
Block a user