Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd00175912 | |||
| 9c9663eb88 | |||
| 402b5c30c6 | |||
| 2bb0381de4 | |||
| 614b0a5ffd | |||
| b1569a1ce2 | |||
| 2c35c6b8aa | |||
| bff150b1a7 | |||
| 8ffb1e5f6a | |||
| 42ff5b6226 | |||
| 539c503d0d | |||
| 4c473a2ffd | |||
| 4f64a94a15 | |||
| 5a82243965 | |||
| ebb14ee302 | |||
| 80f5e46d88 | |||
| b826b5b741 | |||
| 41228aca53 | |||
| cd3b7c1e3b | |||
| ecd525d782 |
@@ -256,6 +256,10 @@ jobs:
|
||||
# Analytics
|
||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
|
||||
# Notifications
|
||||
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -309,6 +313,10 @@ jobs:
|
||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||
echo ""
|
||||
echo "# Notifications"
|
||||
echo "GOTIFY_URL=$GOTIFY_URL"
|
||||
echo "GOTIFY_TOKEN=$GOTIFY_TOKEN"
|
||||
echo ""
|
||||
echo "TARGET=$TARGET"
|
||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getPageBySlug } from '@/lib/pages';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
@@ -20,9 +20,12 @@ export default async function Image({
|
||||
}
|
||||
|
||||
const fonts = await getOgFonts();
|
||||
const { logo, bgImage } = getOgAssets();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
image={bgImage}
|
||||
logo={logo}
|
||||
title={pageData.frontmatter.title}
|
||||
description={pageData.frontmatter.excerpt}
|
||||
label="Information"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getPostBySlug } from '@/lib/blog';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
@@ -35,6 +35,7 @@ export default async function Image({
|
||||
}
|
||||
|
||||
const fonts = await getOgFonts();
|
||||
const { logo, bgImage } = getOgAssets();
|
||||
|
||||
// We don't have request.url here, but we can assume the domain from SITE_URL or config
|
||||
// For local images during dev, relative paths in <img> might not work in Satori
|
||||
@@ -55,10 +56,11 @@ export default async function Image({
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
logo={logo}
|
||||
title={post.frontmatter.title}
|
||||
description={post.frontmatter.excerpt}
|
||||
label={post.frontmatter.category || 'Blog'}
|
||||
image={base64Image || featuredImage}
|
||||
image={base64Image || featuredImage || bgImage}
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
@@ -11,9 +11,16 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Blog.meta' });
|
||||
const fonts = await getOgFonts();
|
||||
const { logo, bgImage } = getOgAssets();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate title={t('title')} description={t('description')} label="Blog" />,
|
||||
<OGImageTemplate
|
||||
image={bgImage}
|
||||
logo={logo}
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
label="Blog"
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
|
||||
@@ -3,7 +3,7 @@ import Image from 'next/image';
|
||||
import { getAllPosts } from '@/lib/blog';
|
||||
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { BlogPaginationKeyboardObserver } from '@/components/blog/BlogPaginationKeyboardObserver';
|
||||
@@ -12,6 +12,7 @@ interface BlogIndexProps {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
}>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: BlogIndexProps) {
|
||||
@@ -41,8 +42,10 @@ export async function generateMetadata({ params }: BlogIndexProps) {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
export default async function BlogIndex({ params, searchParams }: BlogIndexProps) {
|
||||
const { locale } = await params;
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const categoryParam = resolvedSearchParams.category as string | undefined;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations('Blog');
|
||||
const posts = await getAllPosts(locale);
|
||||
@@ -52,8 +55,14 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||
);
|
||||
|
||||
const featuredPost = sortedPosts[0];
|
||||
const remainingPosts = sortedPosts.slice(1);
|
||||
const filteredPosts = categoryParam
|
||||
? sortedPosts.filter(
|
||||
(post) => post.frontmatter.category?.toLowerCase() === categoryParam.toLowerCase(),
|
||||
)
|
||||
: sortedPosts;
|
||||
|
||||
const featuredPost = filteredPosts[0];
|
||||
const remainingPosts = filteredPosts.slice(1);
|
||||
|
||||
return (
|
||||
<div className="bg-neutral-light min-h-screen">
|
||||
@@ -123,31 +132,38 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
{t('allArticles')}
|
||||
</Heading>
|
||||
<div className="flex flex-wrap gap-2 md:gap-4">
|
||||
{/* Category filters could go here */}
|
||||
<Badge
|
||||
variant="primary"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.all')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.industry')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.technical')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.sustainability')}
|
||||
</Badge>
|
||||
<Link href={`/${locale}/blog`}>
|
||||
<Badge
|
||||
variant={!categoryParam ? 'primary' : 'neutral'}
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.all')}
|
||||
</Badge>
|
||||
</Link>
|
||||
<Link href={`/${locale}/blog?category=industry`}>
|
||||
<Badge
|
||||
variant={categoryParam === 'industry' ? 'primary' : 'neutral'}
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.industry')}
|
||||
</Badge>
|
||||
</Link>
|
||||
<Link href={`/${locale}/blog?category=technical`}>
|
||||
<Badge
|
||||
variant={categoryParam === 'technical' ? 'primary' : 'neutral'}
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.technical')}
|
||||
</Badge>
|
||||
</Link>
|
||||
<Link href={`/${locale}/blog?category=sustainability`}>
|
||||
<Badge
|
||||
variant={categoryParam === 'sustainability' ? 'primary' : 'neutral'}
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.sustainability')}
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
@@ -260,7 +276,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
{t('prev')}
|
||||
</Button>
|
||||
<Button
|
||||
href={`/${locale}/blog?page=1`}
|
||||
href={`/${locale}/blog?page=1${categoryParam ? `&category=${categoryParam}` : ''}`}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="md:h-11 md:px-6 md:text-base"
|
||||
@@ -269,7 +285,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
1
|
||||
</Button>
|
||||
<Button
|
||||
href={`/${locale}/blog?page=2`}
|
||||
href={`/${locale}/blog?page=2${categoryParam ? `&category=${categoryParam}` : ''}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="md:h-11 md:px-6 md:text-base"
|
||||
@@ -277,7 +293,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
2
|
||||
</Button>
|
||||
<Button
|
||||
href={`/${locale}/blog?page=2`}
|
||||
href={`/${locale}/blog?page=2${categoryParam ? `&category=${categoryParam}` : ''}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="md:h-11 md:px-6 md:text-base"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
@@ -11,12 +11,19 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Contact' });
|
||||
const fonts = await getOgFonts();
|
||||
const { logo, bgImage } = getOgAssets();
|
||||
|
||||
const title = t('meta.title') || t('title');
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate title={title} description={description} label="Contact" />,
|
||||
<OGImageTemplate
|
||||
image={bgImage}
|
||||
logo={logo}
|
||||
title={title}
|
||||
description={description}
|
||||
label="Contact"
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
@@ -11,9 +11,12 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Index.meta' });
|
||||
const fonts = await getOgFonts();
|
||||
const { logo, bgImage } = getOgAssets();
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate
|
||||
image={bgImage}
|
||||
logo={logo}
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
label="Reliable Energy Infrastructure"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
@@ -11,6 +11,7 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Products' });
|
||||
const fonts = await getOgFonts();
|
||||
const { logo, bgImage } = getOgAssets();
|
||||
|
||||
const title = t.has('meta.title')
|
||||
? t('meta.title')
|
||||
@@ -20,7 +21,13 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
||||
const description = t('meta.description') || t('subtitle');
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate title={title} description={description} label="Products" />,
|
||||
<OGImageTemplate
|
||||
image={bgImage}
|
||||
logo={logo}
|
||||
title={title}
|
||||
description={description}
|
||||
label="Products"
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { OGImageTemplate } from '@/components/OGImageTemplate';
|
||||
import { getOgFonts, OG_IMAGE_SIZE } from '@/lib/og-helper';
|
||||
import { getOgFonts, OG_IMAGE_SIZE, getOgAssets } from '@/lib/og-helper';
|
||||
|
||||
export const size = OG_IMAGE_SIZE;
|
||||
export const contentType = 'image/png';
|
||||
@@ -11,12 +11,19 @@ export default async function Image({ params }: { params: Promise<{ locale: stri
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'Team' });
|
||||
const fonts = await getOgFonts();
|
||||
const { logo, bgImage } = getOgAssets();
|
||||
|
||||
const title = t('meta.title') || t('hero.subtitle');
|
||||
const description = t('meta.description') || t('hero.title');
|
||||
|
||||
return new ImageResponse(
|
||||
<OGImageTemplate title={title} description={description} label="Our Team" />,
|
||||
<OGImageTemplate
|
||||
image={bgImage}
|
||||
logo={logo}
|
||||
title={title}
|
||||
description={description}
|
||||
label="Our Team"
|
||||
/>,
|
||||
{
|
||||
...OG_IMAGE_SIZE,
|
||||
fonts,
|
||||
|
||||
@@ -32,18 +32,24 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
const productName = formData.get('productName') as string | null;
|
||||
|
||||
if (!name || !email || !message) {
|
||||
logger.warn('Missing required fields in contact form', {
|
||||
name: !!name,
|
||||
email: !!email,
|
||||
message: !!message,
|
||||
});
|
||||
logger.warn(
|
||||
{
|
||||
name: !!name,
|
||||
email: !!email,
|
||||
message: !!message,
|
||||
},
|
||||
'Missing required fields in contact form',
|
||||
);
|
||||
return { success: false, error: 'Missing required fields' };
|
||||
}
|
||||
|
||||
logger.info('Payload CMS saving skipped because it has been removed', {
|
||||
type: productName ? 'product_quote' : 'contact',
|
||||
email,
|
||||
});
|
||||
logger.info(
|
||||
{
|
||||
type: productName ? 'product_quote' : 'contact',
|
||||
email,
|
||||
},
|
||||
'Payload CMS saving skipped because it has been removed',
|
||||
);
|
||||
|
||||
// 1.5. Simple Fail-Safe Backup to Disk
|
||||
// To ensure leads are never lost if email fails or Gotify is down, we append them to a local JSON Lines file.
|
||||
@@ -61,14 +67,14 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
productName,
|
||||
message,
|
||||
};
|
||||
fs.appendFileSync(backupFile, JSON.stringify(leadData) + '\\n');
|
||||
logger.info('Successfully saved lead to local backup file', { backupFile });
|
||||
fs.appendFileSync(backupFile, JSON.stringify(leadData) + '\n');
|
||||
logger.info({ backupFile }, 'Successfully saved lead to local backup file');
|
||||
} catch (backupError) {
|
||||
logger.error('Failed to write to local leads backup', { error: String(backupError) });
|
||||
logger.error({ error: String(backupError) }, 'Failed to write to local leads backup');
|
||||
}
|
||||
|
||||
// 2. Send Emails
|
||||
logger.info('Sending branded emails', { email, productName });
|
||||
logger.info({ email, productName }, 'Sending branded emails');
|
||||
|
||||
const notificationSubject = productName
|
||||
? `Product Inquiry: ${productName}`
|
||||
@@ -88,7 +94,7 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
);
|
||||
|
||||
if (!isTestSubmission) {
|
||||
logger.info('Sending internal notification', { recipients: env.MAIL_RECIPIENTS });
|
||||
logger.info({ recipients: env.MAIL_RECIPIENTS }, 'Sending internal notification');
|
||||
const notificationResult = await sendEmail({
|
||||
replyTo: email,
|
||||
subject: notificationSubject,
|
||||
@@ -96,15 +102,21 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
});
|
||||
|
||||
if (notificationResult.success) {
|
||||
logger.info('Notification email sent successfully', {
|
||||
messageId: notificationResult.messageId,
|
||||
});
|
||||
logger.info(
|
||||
{
|
||||
messageId: notificationResult.messageId,
|
||||
},
|
||||
'Notification email sent successfully',
|
||||
);
|
||||
} else {
|
||||
logger.error('Notification email DELIVERY FAILED', {
|
||||
error: notificationResult.error,
|
||||
subject: notificationSubject,
|
||||
recipients: env.MAIL_RECIPIENTS,
|
||||
});
|
||||
logger.error(
|
||||
{
|
||||
error: notificationResult.error,
|
||||
subject: notificationSubject,
|
||||
recipients: env.MAIL_RECIPIENTS,
|
||||
},
|
||||
'Notification email DELIVERY FAILED',
|
||||
);
|
||||
services.errors.captureException(
|
||||
new Error(`Notification email failed: ${notificationResult.error}`),
|
||||
{
|
||||
@@ -115,7 +127,7 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info('Skipping notification email for test submission', { email });
|
||||
logger.info({ email }, 'Skipping notification email for test submission');
|
||||
}
|
||||
|
||||
// 2b. Send confirmation to Customer (branded as KLZ Cables)
|
||||
@@ -128,7 +140,7 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
);
|
||||
|
||||
if (!isTestSubmission) {
|
||||
logger.info('Sending customer confirmation', { to: email });
|
||||
logger.info({ to: email }, 'Sending customer confirmation');
|
||||
const confirmationResult = await sendEmail({
|
||||
to: email,
|
||||
subject: confirmationSubject,
|
||||
@@ -136,27 +148,33 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
});
|
||||
|
||||
if (confirmationResult.success) {
|
||||
logger.info('Confirmation email sent successfully', {
|
||||
messageId: confirmationResult.messageId,
|
||||
});
|
||||
logger.info(
|
||||
{
|
||||
messageId: confirmationResult.messageId,
|
||||
},
|
||||
'Confirmation email sent successfully',
|
||||
);
|
||||
} else {
|
||||
logger.error('Confirmation email DELIVERY FAILED', {
|
||||
error: confirmationResult.error,
|
||||
subject: confirmationSubject,
|
||||
to: email,
|
||||
});
|
||||
logger.error(
|
||||
{
|
||||
error: confirmationResult.error,
|
||||
subject: confirmationSubject,
|
||||
to: email,
|
||||
},
|
||||
'Confirmation email DELIVERY FAILED',
|
||||
);
|
||||
services.errors.captureException(
|
||||
new Error(`Confirmation email failed: ${confirmationResult.error}`),
|
||||
{ action: 'sendContactFormAction_confirmation', email },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info('Skipping confirmation email for test submission', { email });
|
||||
logger.info({ email }, 'Skipping confirmation email for test submission');
|
||||
}
|
||||
|
||||
// Notify via Gotify (Internal)
|
||||
await services.notifications.notify({
|
||||
title: `📩 ${notificationSubject}`,
|
||||
title: `📩 [KLZ] ${notificationSubject}`,
|
||||
message: `New message from ${name} (${email}):\n\n${message}`,
|
||||
priority: 5,
|
||||
});
|
||||
@@ -169,10 +187,13 @@ export async function sendContactFormAction(formData: FormData) {
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
logger.error('Failed to send branded emails', {
|
||||
error: errorMsg,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
logger.error(
|
||||
{
|
||||
error: errorMsg,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
'Failed to send branded emails',
|
||||
);
|
||||
|
||||
services.errors.captureException(error, { action: 'sendContactFormAction', email });
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ interface OGImageTemplateProps {
|
||||
description?: string;
|
||||
label?: string;
|
||||
image?: string;
|
||||
logo?: string;
|
||||
mode?: 'dark' | 'light' | 'image';
|
||||
}
|
||||
|
||||
@@ -13,6 +14,7 @@ export function OGImageTemplate({
|
||||
description,
|
||||
label,
|
||||
image,
|
||||
logo,
|
||||
mode = 'dark',
|
||||
}: OGImageTemplateProps) {
|
||||
const primaryBlue = '#001a4d';
|
||||
@@ -152,21 +154,26 @@ export function OGImageTemplate({
|
||||
height: '6px',
|
||||
backgroundColor: accentGreen,
|
||||
borderRadius: '3px',
|
||||
marginRight: '24px',
|
||||
marginRight: '32px',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.15em',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
KLZ Cables
|
||||
</div>
|
||||
{logo ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img src={logo} alt="KLZ Cables" height="40" style={{ objectFit: 'contain' }} />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.15em',
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
KLZ Cables
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Saturated Blue Brand Strip */}
|
||||
|
||||
@@ -65,11 +65,8 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
|
||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||
const { data, content } = matter(fileContent);
|
||||
|
||||
// Fix MDX data props dropped by next-mdx-remote
|
||||
// Payload serializes as data={{"items":...}} which Acorn treats as a Block statement.
|
||||
const fixedContent = content.replace(/data=\{\{([\s\S]*?)\}\}/g, (match, p1) => {
|
||||
return 'data="{' + p1.replace(/"/g, '"') + '}"';
|
||||
});
|
||||
const { fixMdxDataProps } = await import('./mdx-utils');
|
||||
const fixedContent = fixMdxDataProps(content);
|
||||
|
||||
let parsedContent = fixedContent;
|
||||
try {
|
||||
|
||||
46
lib/mdx-utils.ts
Normal file
46
lib/mdx-utils.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export function fixMdxDataProps(content: string): string {
|
||||
let fixedContent = content;
|
||||
let dataIndex = fixedContent.indexOf('data={{');
|
||||
|
||||
while (dataIndex !== -1) {
|
||||
let openCount = 0;
|
||||
let endIndex = -1;
|
||||
const startObj = dataIndex + 6; // index of the first '{' in 'data={{'
|
||||
|
||||
for (let i = startObj; i < fixedContent.length; i++) {
|
||||
if (fixedContent[i] === '{') {
|
||||
openCount++;
|
||||
} else if (fixedContent[i] === '}') {
|
||||
openCount--;
|
||||
if (openCount === 0) {
|
||||
endIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (endIndex !== -1) {
|
||||
// jsonStr is the content INSIDE the outer curly braces
|
||||
const jsonStr = fixedContent.substring(startObj + 1, endIndex);
|
||||
const safeJsonStr = jsonStr.replace(/"/g, '"');
|
||||
const replacement = `data="{${safeJsonStr}}"`;
|
||||
|
||||
// We also need to consume the closing `}` of the `data={{`
|
||||
const nextCharIndex = endIndex + 1;
|
||||
const skipChars =
|
||||
nextCharIndex < fixedContent.length && fixedContent[nextCharIndex] === '}' ? 2 : 1;
|
||||
|
||||
fixedContent =
|
||||
fixedContent.substring(0, dataIndex) +
|
||||
replacement +
|
||||
fixedContent.substring(endIndex + skipChars);
|
||||
|
||||
dataIndex = fixedContent.indexOf('data={{', dataIndex + replacement.length);
|
||||
} else {
|
||||
// Malformed braces, prevent infinite loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return fixedContent;
|
||||
}
|
||||
@@ -55,3 +55,36 @@ export const OG_IMAGE_SIZE = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads a local image and converts it to a base64 data URI for Satori
|
||||
*/
|
||||
export function getLocalImageBase64(filename: string): string | undefined {
|
||||
try {
|
||||
const filePath = join(process.cwd(), 'public', filename);
|
||||
const buffer = readFileSync(filePath);
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
const mimeType =
|
||||
ext === 'png'
|
||||
? 'image/png'
|
||||
: ext === 'jpg' || ext === 'jpeg'
|
||||
? 'image/jpeg'
|
||||
: 'image/svg+xml';
|
||||
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
||||
} catch (error) {
|
||||
console.error(`[OG] Failed to load local image ${filename}:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns standard assets for OG images
|
||||
*/
|
||||
export function getOgAssets() {
|
||||
return {
|
||||
logo: getLocalImageBase64('logo-white.png'),
|
||||
bgImage: getLocalImageBase64(
|
||||
'media/aerial-view-of-electricity-station-surrounded-with-2023-11-27-05-33-40-utc-scaled.jpg',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,11 +53,8 @@ export async function getPageBySlug(slug: string, locale: string): Promise<PageD
|
||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||
const { data, content } = matter(fileContent);
|
||||
|
||||
// Fix MDX data props dropped by next-mdx-remote
|
||||
// Payload serializes as data={{"items":...}} which Acorn treats as a Block statement.
|
||||
const fixedContent = content.replace(/data=\{\{([\s\S]*?)\}\}/g, (match, p1) => {
|
||||
return 'data="{' + p1.replace(/"/g, '"') + '}"';
|
||||
});
|
||||
const { fixMdxDataProps } = await import('./mdx-utils');
|
||||
const fixedContent = fixMdxDataProps(content);
|
||||
|
||||
let parsedContent = fixedContent;
|
||||
try {
|
||||
|
||||
@@ -307,7 +307,7 @@ const styles = StyleSheet.create({
|
||||
nameText: {
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
color: COLORS.neutralDark,
|
||||
color: COLORS.primary,
|
||||
marginBottom: 1.5,
|
||||
letterSpacing: -0.15,
|
||||
},
|
||||
@@ -334,7 +334,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
contactText: {
|
||||
fontSize: 7.5,
|
||||
color: COLORS.neutralDark,
|
||||
color: COLORS.primary,
|
||||
fontWeight: 'normal',
|
||||
paddingTop: 0.5,
|
||||
letterSpacing: 0.1,
|
||||
@@ -372,7 +372,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
addressFooterText: {
|
||||
fontSize: 6.5,
|
||||
color: COLORS.grayText,
|
||||
color: COLORS.primary,
|
||||
letterSpacing: 0.4,
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
@@ -383,7 +383,7 @@ const PhoneIcon = () => (
|
||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||
<Path
|
||||
d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"
|
||||
fill="#000000"
|
||||
fill={COLORS.primary}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
@@ -392,7 +392,7 @@ const EmailIcon = () => (
|
||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||
<Path
|
||||
d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"
|
||||
fill="#000000"
|
||||
fill={COLORS.primary}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
@@ -401,7 +401,7 @@ const GlobeIcon = () => (
|
||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||
<Path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"
|
||||
fill="#000000"
|
||||
fill={COLORS.primary}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
@@ -410,7 +410,7 @@ const LocationIcon = () => (
|
||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||
<Path
|
||||
d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"
|
||||
fill="#000000"
|
||||
fill={COLORS.primary}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
@@ -419,7 +419,7 @@ const ScanIcon = () => (
|
||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||
<Path
|
||||
d="M3 3h6v2H5v4H3V3zm18 0h-6v2h4v4h2V3zM3 21h6v-2H5v-4H3v6zm18 0h-6v-2h4v-4h2v6zM7 7h10v10H7V7z"
|
||||
fill="#000000"
|
||||
fill={COLORS.primary}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
@@ -455,6 +455,7 @@ const VectorLogo = ({
|
||||
style,
|
||||
overrideColor,
|
||||
overrideWhiteColor,
|
||||
overrideBlackColor,
|
||||
pathOverrides,
|
||||
solidPaths,
|
||||
solidBackgroundColor = '#000a66',
|
||||
@@ -463,6 +464,7 @@ const VectorLogo = ({
|
||||
style: any;
|
||||
overrideColor?: string;
|
||||
overrideWhiteColor?: string;
|
||||
overrideBlackColor?: string;
|
||||
pathOverrides?: Record<number, string>;
|
||||
solidPaths?: number[];
|
||||
solidBackgroundColor?: string;
|
||||
@@ -492,6 +494,11 @@ const VectorLogo = ({
|
||||
(p.fill === 'white' || p.fill === '#ffffff' || p.fill === '#fff')
|
||||
) {
|
||||
fill = overrideWhiteColor;
|
||||
} else if (
|
||||
overrideBlackColor &&
|
||||
(p.fill === 'black' || p.fill === '#000000' || p.fill === '#0a0a0a' || p.fill === '#000')
|
||||
) {
|
||||
fill = overrideBlackColor;
|
||||
}
|
||||
return <Path key={i} d={d} fill={fill} />;
|
||||
})}
|
||||
@@ -824,6 +831,8 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
||||
? path.join(process.cwd(), 'public', 'media', 'cable_drums_truck.png')
|
||||
: path.join(process.cwd(), 'public', 'cable_drums_bg.png');
|
||||
|
||||
const mmToPt = (mm: number) => (mm * 72) / 25.4;
|
||||
|
||||
const renderPageWrapper = (children: React.ReactNode, bleedStyle: any) => {
|
||||
if (withCropMarks) {
|
||||
return (
|
||||
@@ -838,7 +847,7 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Page size={[258, 173]} style={bleedStyle}>
|
||||
<Page size={[mmToPt(91), mmToPt(61)]} style={bleedStyle}>
|
||||
{children}
|
||||
</Page>
|
||||
);
|
||||
@@ -905,16 +914,6 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
||||
solidPaths={KLZ_PATHS}
|
||||
solidBackgroundColor={isWhiteVariant ? '#ffffff' : COLORS.primary}
|
||||
/>
|
||||
|
||||
{/* Letterpress Mask Layer: We use #ff00fe as a marker color. Only the "KLZ" letters! */}
|
||||
{isViaprinto && (
|
||||
<VectorLogo
|
||||
paths={getFrontLogoPaths().filter((_, i) => KLZ_PATHS.includes(i))}
|
||||
style={[styles.frontLogo, { position: 'absolute' }]}
|
||||
overrideColor="#ff00fe"
|
||||
solidPaths={KLZ_PATHS.map((_, i) => i)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</>,
|
||||
[styles.pageBleed, isWhiteVariant && { backgroundColor: '#ffffff' }],
|
||||
@@ -926,7 +925,7 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
||||
<VectorLogo
|
||||
paths={getBackLogoPaths()}
|
||||
style={styles.backLogo}
|
||||
overrideWhiteColor={COLORS.black}
|
||||
overrideBlackColor={COLORS.primary}
|
||||
pathOverrides={{
|
||||
...(KLZ_PATHS.reduce(
|
||||
(acc: Record<number, string>, idx) => ({ ...acc, [idx]: 'none' }),
|
||||
|
||||
@@ -39,11 +39,8 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
|
||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||
const { data, content } = matter(fileContent);
|
||||
|
||||
// Fix MDX data props dropped by next-mdx-remote
|
||||
// Payload serializes as data={{"items":...}} which Acorn treats as a Block statement.
|
||||
const fixedContent = content.replace(/data=\{\{([\s\S]*?)\}\}/g, (match, p1) => {
|
||||
return 'data="{' + p1.replace(/"/g, '"') + '}"';
|
||||
});
|
||||
const { fixMdxDataProps } = await import('./mdx-utils');
|
||||
const fixedContent = fixMdxDataProps(content);
|
||||
|
||||
let parsedContent = fixedContent;
|
||||
try {
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"prepare": "husky",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"version": "2.3.25",
|
||||
"version": "2.3.29",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -71,7 +71,7 @@ async function generateBusinessCards() {
|
||||
width: 400,
|
||||
margin: 0,
|
||||
errorCorrectionLevel: 'L', // Low error correction = less dots
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
color: { dark: '#000a66', light: '#ffffff' },
|
||||
});
|
||||
|
||||
const safeName = person.name.replace(/\s+/g, '_');
|
||||
@@ -121,7 +121,36 @@ async function generateBusinessCards() {
|
||||
try {
|
||||
execSync(cmykCommand, { stdio: 'inherit' });
|
||||
fs.unlinkSync(tempViaprintoPath);
|
||||
console.log(`✓ CMYK Print File generated: ${finalFileNameCMYK}`);
|
||||
console.log(`[INFO] Injecting PANTONE 289 C Spot Color...`);
|
||||
const injectPantoneCommand = `npx tsx scripts/inject-pantone.ts "${finalPathCMYK}"`;
|
||||
execSync(injectPantoneCommand, { stdio: 'inherit' });
|
||||
|
||||
console.log(`✓ 5-Color Print File generated: ${finalFileNameCMYK}`);
|
||||
} catch (e) {
|
||||
console.error(`❌ CMYK conversion failed! Is Ghostscript installed?`);
|
||||
}
|
||||
|
||||
// 2.5 SAXOPRINT PDF (Pure CMYK, no mask, no Pantone)
|
||||
console.log(`- Rendering Saxoprint PDF (Pure CMYK)...`);
|
||||
const tempSaxoprintPath = path.join(CONFIG.outputDir, `temp_saxoprint_${safeName}.pdf`);
|
||||
buffer = await renderToBuffer(
|
||||
React.createElement(PDFBusinessCard, {
|
||||
person,
|
||||
qrCodeDataUrl,
|
||||
variant: 'blue',
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(tempSaxoprintPath, buffer);
|
||||
|
||||
const finalFileNameSaxoCMYK = `KLZ_Visitenkarte_${safeName}_Saxoprint_CMYK_91x61mm_3mmBleed.pdf`;
|
||||
const finalPathSaxoCMYK = path.join(CONFIG.outputDir, finalFileNameSaxoCMYK);
|
||||
|
||||
console.log(`- Converting to CMYK for Saxoprint...`);
|
||||
const cmykSaxoCommand = `bash "${CONFIG.ghostscriptScript}" "${tempSaxoprintPath}" "${finalPathSaxoCMYK}"`;
|
||||
try {
|
||||
execSync(cmykSaxoCommand, { stdio: 'inherit' });
|
||||
fs.unlinkSync(tempSaxoprintPath);
|
||||
console.log(`✓ 4-Color CMYK Print File generated: ${finalFileNameSaxoCMYK}`);
|
||||
} catch (e) {
|
||||
console.error(`❌ CMYK conversion failed! Is Ghostscript installed?`);
|
||||
}
|
||||
|
||||
105
scripts/inject-pantone.ts
Normal file
105
scripts/inject-pantone.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { PDFDocument, PDFName, decodePDFRawStream, PDFRawStream } from 'pdf-lib';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
async function run() {
|
||||
const inputPath = process.argv[2];
|
||||
const outputPath = process.argv[3] || inputPath;
|
||||
|
||||
if (!inputPath || !fs.existsSync(inputPath)) {
|
||||
console.error(`[ERROR] Input file not found: ${inputPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[INFO] Injecting PANTONE 289 C into: ${path.basename(inputPath)}`);
|
||||
const pdfBytes = fs.readFileSync(inputPath);
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
|
||||
// CMYK tint transform for PANTONE 289 C.
|
||||
// Approximation: 100C, 64M, 0Y, 60K (1.0 0.64 0.0 0.60)
|
||||
// The function maps an input x (0 to 1) to CMYK: x*1.0, x*0.64, x*0.0, x*0.60
|
||||
const tintTransform = pdfDoc.context.obj({
|
||||
FunctionType: 2,
|
||||
Domain: [0, 1],
|
||||
C0: [0, 0, 0, 0],
|
||||
C1: [1.0, 0.64, 0.0, 0.6],
|
||||
N: 1,
|
||||
});
|
||||
|
||||
// Create the /Separation color space array
|
||||
const pantoneCS = pdfDoc.context.obj([
|
||||
PDFName.of('Separation'),
|
||||
PDFName.of('PANTONE#20289#20C'),
|
||||
PDFName.of('DeviceCMYK'),
|
||||
tintTransform,
|
||||
]);
|
||||
const csRef = pdfDoc.context.register(pantoneCS);
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
let resources = page.node.Resources();
|
||||
if (!resources) {
|
||||
resources = pdfDoc.context.obj({});
|
||||
page.node.set(PDFName.of('Resources'), resources);
|
||||
}
|
||||
|
||||
// Ensure ColorSpace dict exists
|
||||
let colorSpaces = resources.get(PDFName.of('ColorSpace')) as any;
|
||||
if (!colorSpaces) {
|
||||
colorSpaces = pdfDoc.context.obj({});
|
||||
resources.set(PDFName.of('ColorSpace'), colorSpaces);
|
||||
}
|
||||
// Register the PANTONE color space as /CSPantone in the page's resources
|
||||
colorSpaces.set(PDFName.of('CSPantone'), csRef);
|
||||
|
||||
// Read all content streams
|
||||
const contents = page.node.Contents();
|
||||
let streams: any[] = [];
|
||||
if (contents) {
|
||||
const resolvedContents = pdfDoc.context.lookup(contents);
|
||||
if (resolvedContents instanceof PDFRawStream) {
|
||||
streams = [resolvedContents];
|
||||
} else if (resolvedContents.constructor.name === 'PDFArray') {
|
||||
const arr = resolvedContents as any;
|
||||
for (let j = 0; j < arr.size(); j++) {
|
||||
streams.push(pdfDoc.context.lookup(arr.get(j)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let newStr = '';
|
||||
for (let i = 0; i < streams.length; i++) {
|
||||
const stream = streams[i];
|
||||
if (stream instanceof PDFRawStream) {
|
||||
const decoded = decodePDFRawStream(stream).decode();
|
||||
const str = Buffer.from(decoded).toString('utf-8');
|
||||
newStr += str + '\n';
|
||||
}
|
||||
}
|
||||
|
||||
// The CMYK values for the KLZ Blue created by Ghostscript from #000a66
|
||||
// is '1 0.98 0.224 0.298'. We use a slightly dynamic regex to catch small precision diffs.
|
||||
const dynamicRegexK = /1 0\.98[0-9]* 0\.224[0-9]* 0\.298[0-9]* k/g;
|
||||
const dynamicRegexStrokeK = /1 0\.98[0-9]* 0\.224[0-9]* 0\.298[0-9]* K/g;
|
||||
|
||||
let modified = false;
|
||||
if (dynamicRegexK.test(newStr) || dynamicRegexStrokeK.test(newStr)) {
|
||||
newStr = newStr.replace(dynamicRegexK, '/CSPantone cs 1 scn');
|
||||
newStr = newStr.replace(dynamicRegexStrokeK, '/CSPantone CS 1 SCN');
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
const newStream = pdfDoc.context.flateStream(newStr);
|
||||
const newRef = pdfDoc.context.register(newStream);
|
||||
page.node.set(PDFName.of('Contents'), newRef);
|
||||
console.log(`[INFO] Spot color injected into page ${pages.indexOf(page) + 1}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const finalPdfBytes = await pdfDoc.save();
|
||||
fs.writeFileSync(outputPath, finalPdfBytes);
|
||||
console.log(`[SUCCESS] Saved 5-color PDF to ${outputPath}`);
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
@@ -12,4 +12,5 @@ Sentry.init({
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
ignoreErrors: ['failed to pipe response'],
|
||||
});
|
||||
|
||||
@@ -12,4 +12,5 @@ Sentry.init({
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
ignoreErrors: ['failed to pipe response'],
|
||||
});
|
||||
|
||||
17
tests/mdx-regex.test.ts
Normal file
17
tests/mdx-regex.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fixMdxDataProps } from '../lib/mdx-utils';
|
||||
|
||||
describe('MDX Data Props Fixer', () => {
|
||||
it('should correctly parse MDX with nested JSON containing }}', () => {
|
||||
// This string simulates the exact structure that causes the bug in n2x2y.mdx
|
||||
const mdxInput = `<Block type="productTabs" data={{"content":{"root":{"children":[]}},"id":"123"}} />`;
|
||||
|
||||
const result = fixMdxDataProps(mdxInput);
|
||||
|
||||
// The expected output should have the entire JSON object enclosed in data="{...}"
|
||||
// and NO trailing characters left over from the regex truncating early.
|
||||
const expected = `<Block type="productTabs" data="{"content":{"root":{"children":[]}},"id":"123"}" />`;
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user