/**
* Build a fully static storefront into dist/ for Cloudflare Workers.
* Reads data/site.json only — no database required on CI.
*/
import fs from "node:fs";
import { assertPersianProductCopy } from "../src/lib/sync/persian-translator";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { getDictionary } from "../src/lib/i18n/dictionaries";
import {
KIKO_CATEGORY_ROOTS,
slugifySegment,
parseKikoPath,
translateKikoSegment,
commercialProductSearchTerm,
commercialProductType,
} from "../src/lib/categories/kiko";
import { formatPrice, parseJsonObject } from "../src/lib/utils";
import { rankRelatedProducts } from "../src/lib/related-products";
import {
ROBOTS_TXT,
aboutPageJsonLd,
authenticityPageJsonLd,
breadcrumbJsonLd,
contactPageJsonLd,
buildSitemapXml,
faqJsonLd,
jsonLdGraph,
listingJsonLd,
organizationJsonLd,
productBreadcrumbItems,
productJsonLd,
seoAboutDescription,
seoAboutTitle,
seoAuthenticityDescription,
seoAuthenticityTitle,
seoContactDescription,
seoContactTitle,
seoCategoryDescription,
seoCategoryIntro,
seoHomeDescription,
seoHomeTitle,
seoListingTitle,
seoOffersDescription,
seoProductDescription,
seoProductTitle,
websiteJsonLd,
} from "../src/lib/seo";
import {
buyPanel,
atelierHeroHtml,
bestsellersSection,
beautyInspirationBanner,
collabBannersSection,
faqSection,
layout,
listProductCard,
aboutPageBody,
authenticityPageBody,
contactPageBody,
offerProductCard,
offersSliderSection,
promoBanner,
promoHeroSliderHtml,
reviewsSection,
topCategoriesSection,
kikoShopByCategoryItems,
trustSection,
whyUsSection,
productGridCard,
productListingFilters,
relatedProductCard,
renderBlocks,
pdpGallerySection,
} from "../vanilla/lib/html";
import type { HeroContent } from "../src/lib/blocks/types";
import type { MegaMenuRoot } from "../src/lib/mega-menu";
import {
attachColorVariants,
buildColorVariantMap,
dedupeByColorFamily,
displayNameWithoutShade,
splitProductName,
type ColorVariant,
} from "../src/lib/color-variants";
import {
normalizeProductCategory,
persianCategoryLabel,
withPersianCategory,
} from "../src/lib/categories/kiko";
import { productFacts } from "../src/lib/product-facts";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
const DIST = path.join(ROOT, "dist");
const SITE_JSON = path.join(ROOT, "data", "site.json");
type SiteProduct = {
id: string;
slug: string;
name: string;
nameFa?: string | null;
tagline: string | null;
description: string | null;
seoDescription?: string | null;
price: number;
compareAtPrice: number | null;
heroImage: string | null;
images: string[];
category: string | null;
stock: number;
lowStockThreshold: number;
featured: boolean;
updatedAt: string;
variantGroupId?: string | null;
shadeCode?: string | null;
variantCanonical?: boolean;
related: Array<{
id: string;
slug: string;
name: string;
nameFa?: string | null;
price: number;
heroImage: string | null;
category: string | null;
tagline?: string | null;
colorVariants?: ColorVariant[];
}>;
blocks: Array<{ type: string; content: string }>;
ratingAvg: number;
reviewCount: number;
colorVariants?: ColorVariant[];
};
type SiteData = {
generatedAt: string;
eurToTomanRate?: number;
eurToTomanSource?: string;
megaMenu: MegaMenuRoot[];
products: SiteProduct[];
};
function esc(s: unknown) {
return String(s ?? "")
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function write(rel: string, content: string | Buffer) {
const full = path.join(DIST, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
function redirectHtml(target: string, title = "Kikomiko") {
return `
${esc(title)} ادامه به فروشگاه
`;
}
function productFactsSection(product: SiteProduct) {
const facts = productFacts(product);
return `
اطلاعات رسمی محصول
مشخصات و نحوه استفاده
${facts.map((fact) => `
${esc(fact.label)} ${esc(fact.value)} `).join("")}
`;
}
function productDescriptionHtml(description: string) {
const [intro, ...rest] = description.split(/\n\s*\n/);
return `${esc(intro)} ${rest.length ? `\n\n${esc(rest.join("\n\n"))}` : ""}
`;
}
function writeLegacyFaRedirect(rel: string, target: string, title?: string) {
write(`fa/${rel}`, redirectHtml(target, title));
}
function copyDir(src: string, dest: string) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const from = path.join(src, entry.name);
const to = path.join(dest, entry.name);
if (entry.isDirectory()) copyDir(from, to);
else fs.copyFileSync(from, to);
}
}
function rewriteMegaForStatic(mega: MegaMenuRoot[]): MegaMenuRoot[] {
return mega.map((root) => {
const base = root.href.replace(/^\/fa(?=\/|$)/, "").replace(/\?.*$/, "") || "/";
return {
...root,
href: base.endsWith("/") ? base : `${base}/`,
children: root.children.map((c) => {
const sub = new URL(c.href, "http://local").searchParams.get("sub") || c.key;
const slug = slugifySegment(sub) || c.key;
return {
...c,
label: translateKikoSegment(c.label),
href: `${base.replace(/\/$/, "")}/sub/${slug}/`,
};
}),
};
});
}
function matchesRoot(category: string | null, lev1: string) {
const parts = parseKikoPath(category);
return parts[0]?.toUpperCase() === lev1.toUpperCase();
}
function main() {
console.log("Building static site → dist/ (from data/site.json)");
if (!fs.existsSync(SITE_JSON)) {
throw new Error(
"Missing data/site.json. Run locally: npm run export:catalog"
);
}
const site = JSON.parse(fs.readFileSync(SITE_JSON, "utf8")) as SiteData;
const dict = getDictionary("fa");
const megaMenu = rewriteMegaForStatic(site.megaMenu || []);
const products = (site.products || []).map((p) => ({
...p,
category: normalizeProductCategory(p.name, p.category) || p.category,
}));
products.forEach(assertPersianProductCopy);
const catalogUpdatedAt = site.generatedAt;
const eurToTomanRate =
typeof site.eurToTomanRate === "number" &&
Number.isFinite(site.eurToTomanRate) &&
site.eurToTomanRate > 0
? site.eurToTomanRate
: undefined;
const sitemapPaths: string[] = [];
function pageLayout(opts: Parameters[0]) {
return layout({ catalogUpdatedAt, eurToTomanRate, ...opts });
}
fs.rmSync(DIST, { recursive: true, force: true });
fs.mkdirSync(DIST, { recursive: true });
const listingSource = products.filter((p) => p.variantCanonical !== false);
const variantMapAll = buildColorVariantMap(
products.map((p) => ({
id: p.id,
slug: p.slug,
name: p.name,
category: p.category,
heroImage: p.heroImage,
images: p.images,
stock: p.stock,
price: p.price,
compareAtPrice: p.compareAtPrice,
variantGroupId: p.variantGroupId,
shadeCode: p.shadeCode,
variantCanonical: p.variantCanonical,
}))
);
const listingProducts = attachColorVariants(
dedupeByColorFamily(listingSource),
variantMapAll
).map(
(p) => {
if (p.colorVariants && p.colorVariants.length > 1) return p;
const fromExport = products.find((x) => x.id === p.id)?.colorVariants;
return fromExport && fromExport.length > 1
? { ...p, colorVariants: fromExport }
: p;
}
);
const catalog = {
generatedAt: site.generatedAt,
version: "dedupe2",
products: listingProducts.map((p) => {
const category = normalizeProductCategory(p.name, p.category);
return {
id: p.id,
slug: p.slug,
name: p.name,
nameFa: p.nameFa ?? null,
price: p.price,
compareAtPrice: p.compareAtPrice ?? null,
stock: p.stock,
heroImage: p.heroImage,
category,
categoryFa: persianCategoryLabel(category, p.name),
featured: p.featured,
updatedAt: p.updatedAt,
ratingAvg: p.ratingAvg,
reviewCount: p.reviewCount,
colorVariants: p.colorVariants,
};
}),
};
write("data/catalog.json", JSON.stringify(catalog));
write("data/catalog.dedupe2.json", JSON.stringify(catalog));
copyDir(path.join(ROOT, "vanilla/public/css"), path.join(DIST, "css"));
copyDir(path.join(ROOT, "vanilla/public/js"), path.join(DIST, "js"));
copyDir(path.join(ROOT, "vanilla/public/images"), path.join(DIST, "images"));
for (const rootAsset of [
"favicon.svg",
"60162903.txt",
"37941746.txt",
"۷۲۶۴۵۵۱۷.txt",
]) {
const src = path.join(ROOT, "vanilla/public", rootAsset);
if (fs.existsSync(src)) {
fs.copyFileSync(src, path.join(DIST, rootAsset));
}
}
copyDir(path.join(ROOT, "public/fonts"), path.join(DIST, "fonts"));
// Prefer vanilla/public/videos (storefront source of truth); fall back to public/videos
const vanillaVideos = path.join(ROOT, "vanilla/public/videos");
const publicVideos = path.join(ROOT, "public/videos");
copyDir(
fs.existsSync(vanillaVideos) ? vanillaVideos : publicVideos,
path.join(DIST, "videos")
);
const skipUploads = process.env.SKIP_UPLOADS !== "0";
if (!skipUploads) {
copyDir(path.join(ROOT, "public/uploads"), path.join(DIST, "uploads"));
} else {
console.log("SKIP_UPLOADS (default) — not copying product images");
}
const bestsellers = attachColorVariants(
dedupeByColorFamily(listingSource.filter((p) => p.featured)),
variantMapAll
).slice(0, 12);
const offers70 = (() => {
const eligible = products
.filter(
(p) =>
p.compareAtPrice != null &&
p.compareAtPrice > p.price &&
p.price > 0 &&
p.heroImage &&
Math.round((1 - p.price / p.compareAtPrice) * 100) >= 30
)
.map((p) => ({
...p,
discountPercent: Math.round(
(1 - p.price / (p.compareAtPrice as number)) * 100
),
}))
.sort((a, b) => b.discountPercent - a.discountPercent);
const attached = attachColorVariants(
dedupeByColorFamily(eligible),
variantMapAll
);
// Prefer cards that can show shade dots in the first screenful
const withShades = attached.filter(
(p) => (p.colorVariants?.length || 0) > 1
);
const withoutShades = attached.filter(
(p) => (p.colorVariants?.length || 0) <= 1
);
return [...withShades, ...withoutShades].slice(0, 15);
})();
const HOME_COLLECTION_LIMIT = 20;
const homeWithShades = listingProducts.filter(
(p) => (p.colorVariants?.length || 0) > 1
);
const homeWithoutShades = listingProducts.filter(
(p) => (p.colorVariants?.length || 0) <= 1
);
const homeCollection = [
...homeWithShades,
...homeWithoutShades,
].slice(0, HOME_COLLECTION_LIMIT);
const homeBody = `
${atelierHeroHtml(
products
.map((p) => p.heroImage)
.filter((src): src is string => Boolean(src))
.slice(0, 10)
)}
${promoHeroSliderHtml()}
${topCategoriesSection(kikoShopByCategoryItems(megaMenu))}
${bestsellersSection(dict.bestsellers, bestsellers, { trailingSlash: true })}
${collabBannersSection({ trailingSlash: true })}
${offersSliderSection(dict.offers70, offers70, { trailingSlash: true })}
${whyUsSection()}
${beautyInspirationBanner()}
${reviewsSection()}
${faqSection()}
${promoBanner({
title: "محصولات",
titleBold: "ویژه و تخفیفدار",
sub: "فرصت محدود برای کشف بهترین پیشنهادهای اورجینال کیکو با ضمانت اصالت.",
ctaLabel: "مشاهده پیشنهادها",
ctaHref: "/offers/",
})}
${esc(dict.interested)}
${productListingFilters({
megaMenu,
productCount: homeCollection.length,
currentSort: "newest",
mode: "home",
hash: "collection",
clientMode: true,
})}
${esc(dict.featured)}
جدیدترین محصولات
${homeCollection.map((p) => productGridCard(p, { trailingSlash: true })).join("")}
${trustSection()}
`;
write(
"index.html",
pageLayout({
title: seoHomeTitle(),
description: seoHomeDescription(),
dict,
megaMenu,
canonicalPath: "/",
ogImage: "/videos/kiko-hero-poster.jpg",
body: homeBody,
headExtra: `${jsonLdGraph([organizationJsonLd(), websiteJsonLd(), faqJsonLd()])}
`,
})
);
sitemapPaths.push("/");
writeLegacyFaRedirect("index.html", "/", "Kikomiko");
write(
"collection/index.html",
pageLayout({
title: seoListingTitle("همه محصولات"),
description: seoCategoryDescription("همه محصولات"),
dict,
megaMenu,
canonicalPath: "/collection/",
headExtra: jsonLdGraph([
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: "همه محصولات", path: "/collection/" },
]),
listingJsonLd({
name: "همه محصولات کیکو میلانو",
description: seoCategoryDescription("همه محصولات"),
urlPath: "/collection/",
items: listingProducts.map((p) => ({
name: withPersianCategory(displayNameWithoutShade(p.name, true), p.category),
path: `/products/${p.slug}/`,
})),
}),
]),
body: `
${esc(dict.collection)}
${esc(dict.seeAllProducts)}
${productListingFilters({
megaMenu,
productCount: listingProducts.length,
mode: "collection",
clientMode: true,
})}
${listingProducts
.map((p) => listProductCard(p, { trailingSlash: true }))
.join("")}
`,
})
);
sitemapPaths.push("/collection/");
writeLegacyFaRedirect("collection/index.html", "/collection/", `${dict.collection} | Kikomiko`);
const offersListing = (() => {
const eligible = listingSource
.filter(
(p) =>
p.compareAtPrice != null &&
p.compareAtPrice > p.price &&
p.price > 0 &&
p.heroImage &&
Math.round((1 - p.price / p.compareAtPrice) * 100) >= 30
)
.map((p) => ({
...p,
discountPercent: Math.round(
(1 - p.price / (p.compareAtPrice as number)) * 100
),
}))
.sort((a, b) => b.discountPercent - a.discountPercent);
return attachColorVariants(dedupeByColorFamily(eligible), variantMapAll);
})();
write(
"offers/index.html",
pageLayout({
title: seoListingTitle("محصولات تخفیفدار"),
description: seoOffersDescription(),
dict,
megaMenu,
canonicalPath: "/offers/",
headExtra: jsonLdGraph([
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: "با تخفیف", path: "/offers/" },
]),
listingJsonLd({
name: "محصولات تخفیفدار کیکو میلانو",
description: seoOffersDescription(),
urlPath: "/offers/",
items: offersListing.map((p) => ({
name: withPersianCategory(displayNameWithoutShade(p.name, true), p.category),
path: `/products/${p.slug}/`,
})),
}),
]),
body: `
${esc(dict.offers70)}
${esc(dict.withDiscount)}
محصولات با حداقل ۳۰٪ تخفیف — شامل ۳۰٪، ۵۰٪ و ۷۰٪ و بیشتر
${
offersListing.length === 0
? `
`
: `
${productListingFilters({
megaMenu,
productCount: offersListing.length,
mode: "offers",
clientMode: true,
})}
${offersListing
.map((p) => offerProductCard(p, { trailingSlash: true }))
.join("")}
`
}
`,
})
);
sitemapPaths.push("/offers/");
writeLegacyFaRedirect("offers/index.html", "/offers/", `${dict.withDiscount} | Kikomiko`);
for (const root of KIKO_CATEGORY_ROOTS) {
const inRoot = attachColorVariants(
dedupeByColorFamily(
listingSource.filter((p) => matchesRoot(p.category, root.lev1))
),
variantMapAll
);
write(
`category/${root.slug}/index.html`,
pageLayout({
title: seoListingTitle(root.fa),
description: seoCategoryDescription(root.fa),
dict,
megaMenu,
canonicalPath: `/category/${root.slug}/`,
headExtra: jsonLdGraph([
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: root.fa, path: `/category/${root.slug}/` },
]),
listingJsonLd({
name: root.fa,
description: seoCategoryDescription(root.fa),
urlPath: `/category/${root.slug}/`,
items: inRoot.map((p) => ({
name: withPersianCategory(displayNameWithoutShade(p.name, true), p.category),
path: `/products/${p.slug}/`,
})),
}),
]),
body: categoryBody(dict, root.fa, root.slug, inRoot, null, megaMenu),
})
);
sitemapPaths.push(`/category/${root.slug}/`);
writeLegacyFaRedirect(
`category/${root.slug}/index.html`,
`/category/${root.slug}/`,
`${root.fa} | Kikomiko`
);
const megaRoot = megaMenu.find((m) => m.href.includes(`/category/${root.slug}`));
if (!megaRoot) continue;
for (const child of megaRoot.children) {
const subSlug = child.href.split("/sub/")[1]?.replace(/\/$/, "") || child.key;
const landing = categoryLandingFor(subSlug);
const filtered = attachColorVariants(
dedupeByColorFamily(
listingSource.filter((p) => {
if (!matchesRoot(p.category, root.lev1)) return false;
const parts = parseKikoPath(p.category);
return parts.some(
(part) => slugifySegment(part) === subSlug || part === child.key
);
})
),
variantMapAll
);
write(
`category/${root.slug}/sub/${subSlug}/index.html`,
pageLayout({
title: landing?.title ?? seoListingTitle(child.label),
description: seoCategoryDescription(child.label),
dict,
megaMenu,
canonicalPath: `/category/${root.slug}/sub/${subSlug}/`,
headExtra: jsonLdGraph([
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: root.fa, path: `/category/${root.slug}/` },
{ name: child.label, path: `/category/${root.slug}/sub/${subSlug}/` },
]),
listingJsonLd({
name: child.label,
description: seoCategoryDescription(child.label),
urlPath: `/category/${root.slug}/sub/${subSlug}/`,
items: filtered.map((p) => ({
name: withPersianCategory(displayNameWithoutShade(p.name, true), p.category),
path: `/products/${p.slug}/`,
})),
}),
]),
body: categoryBody(
dict,
child.label,
root.slug,
filtered,
child.label,
megaMenu,
subSlug
),
})
);
sitemapPaths.push(`/category/${root.slug}/sub/${subSlug}/`);
writeLegacyFaRedirect(
`category/${root.slug}/sub/${subSlug}/index.html`,
`/category/${root.slug}/sub/${subSlug}/`,
`${child.label} | Kikomiko`
);
}
}
for (const guide of PRODUCT_TYPE_GUIDES) {
const root = KIKO_CATEGORY_ROOTS.find((item) => item.slug === guide.rootSlug);
if (!root) continue;
const guideProducts = attachColorVariants(
dedupeByColorFamily(
listingSource.filter(
(product) => commercialProductType(product.name, product.category)?.typeFa === guide.typeFa
)
),
variantMapAll
);
const path = `/category/${guide.rootSlug}/type/${guide.slug}/`;
write(
`category/${guide.rootSlug}/type/${guide.slug}/index.html`,
pageLayout({
title: guide.title,
description: guide.description,
dict,
megaMenu,
canonicalPath: path,
headExtra: jsonLdGraph([
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: root.fa, path: `/category/${root.slug}/` },
{ name: guide.typeFa, path },
]),
listingJsonLd({
name: guide.h1,
description: guide.description,
urlPath: path,
items: guideProducts.map((product) => ({
name: withPersianCategory(displayNameWithoutShade(product.name, true), product.category),
path: `/products/${product.slug}/`,
})),
}),
]),
body: productTypeGuideBody(dict, guide, guideProducts, megaMenu),
})
);
sitemapPaths.push(path);
}
const maxProducts = process.env.MAX_PRODUCTS
? Number(process.env.MAX_PRODUCTS)
: products.length;
const slice = products.slice(0, maxProducts);
const byGroup = new Map();
for (const p of products) {
if (!p.variantGroupId) continue;
const list = byGroup.get(p.variantGroupId) || [];
list.push(p);
byGroup.set(p.variantGroupId, list);
}
let i = 0;
for (const product of slice) {
i += 1;
if (i % 100 === 0) console.log(` products ${i}/${slice.length}`);
if (product.variantCanonical === false && product.variantGroupId) {
const family = byGroup.get(product.variantGroupId) || [];
const canonical =
family.find((p) => p.variantCanonical !== false) || family[0];
if (canonical && canonical.slug !== product.slug) {
const shade =
product.shadeCode || splitProductName(product.name).shade || "";
const qs = shade ? `?shade=${encodeURIComponent(shade)}` : "";
const target = `/products/${canonical.slug}/${qs}`;
write(
`products/${product.slug}/index.html`,
`${esc(displayNameWithoutShade(product.name, true))} | Kikomiko مشاهده محصول
`
);
writeLegacyFaRedirect(
`products/${product.slug}/index.html`,
target,
`${displayNameWithoutShade(product.name, true)} | Kikomiko`
);
continue;
}
}
const productImages = product.images?.length
? product.images
: product.heroImage
? [product.heroImage]
: [];
const galleryImages = [
...(product.heroImage ? [product.heroImage] : []),
...productImages.filter((src) => src !== product.heroImage),
];
const heroBlock = product.blocks.find((b) => b.type === "HERO");
const hero = heroBlock
? parseJsonObject(heroBlock.content, {
headline: product.name,
image: product.heroImage || "",
overlay: "dark",
})
: null;
const pdpDiscount =
product.compareAtPrice != null &&
product.compareAtPrice > product.price
? Math.round(
(1 - product.price / product.compareAtPrice) * 100
)
: 0;
const hasGalleryBlock = product.blocks.some((b) => b.type === "GALLERY");
const factsHtml = productFactsSection(product);
const blocksHtml = product.blocks.length
? `${renderBlocks(product.blocks, galleryImages)}${
!hasGalleryBlock && galleryImages.length
? `${pdpGallerySection(galleryImages, pdpDiscount)} `
: ""
}${
product.description
? `معرفی و توضیحات محصول ${productDescriptionHtml(product.description)} `
: ""
}${factsHtml}`
: `${pdpGallerySection(galleryImages, pdpDiscount)}${
product.description
? `معرفی و توضیحات محصول ${productDescriptionHtml(product.description)}`
: ""
} ${factsHtml}`;
const hasVariants = (product.colorVariants?.length || 0) > 1;
const pdpTitle = withPersianCategory(
displayNameWithoutShade(product.name, hasVariants),
product.category
);
const heroHtml = hero?.image
? `
${esc(hero.headline ? withPersianCategory(hero.headline, product.category) : pdpTitle)}
${hero.subheadline ? `
${esc(hero.subheadline)}
` : ""}
${
hero.ctaText && hero.ctaLink
? `
${esc(hero.ctaText)}
`
: ""
}
`
: `
`;
const sticky = `
${esc(pdpTitle)}
${esc(formatPrice(product.price))}
0 ? "" : "disabled"}
>${product.stock > 0 ? esc(dict.addToCart) : esc(dict.soldOut)}
`;
const relatedProducts = rankRelatedProducts(
product,
listingSource.filter((candidate) => Boolean(candidate.heroImage)),
4
);
const relatedHtml =
!relatedProducts.length
? ""
: `
ادامه کاوش
${esc(dict.youMayAlsoLike)}
${relatedProducts
.map((p) => {
const colorVariants =
p.colorVariants && p.colorVariants.length > 1
? p.colorVariants
: variantMapAll.get(p.id);
return relatedProductCard(
{
...p,
colorVariants:
colorVariants && colorVariants.length > 1
? colorVariants
: undefined,
},
{ trailingSlash: true }
);
})
.join("")}
`;
const body = `
${heroHtml}
${buyPanel({
product,
dict,
colorVariants: product.colorVariants,
trailingSlash: true,
})}
${blocksHtml}
${esc(dict.reviews)}
نظرات مشتریان
هنوز نظری ثبت نشده است.
${relatedHtml}
${sticky}
`;
write(
`products/${product.slug}/index.html`,
pageLayout({
title: seoProductTitle(pdpTitle),
description: seoProductDescription({
name: pdpTitle,
categoryFa: persianCategoryLabel(product.category, product.name),
commercialTerm: commercialProductSearchTerm(product.name, product.category),
existing: product.seoDescription || product.tagline || product.description,
}),
dict,
megaMenu,
canonicalPath: `/products/${product.slug}/`,
ogImage: product.heroImage,
ogType: "product",
headExtra: jsonLdGraph([
organizationJsonLd(),
breadcrumbJsonLd(
productBreadcrumbItems({
name: pdpTitle,
slug: product.slug,
category: product.category,
})
),
productJsonLd({
name: pdpTitle,
description:
product.seoDescription || product.tagline || product.description,
image: product.heroImage,
extraImages: galleryImages,
urlPath: `/products/${product.slug}/`,
sku: product.slug,
price: product.price,
compareAtPrice: product.compareAtPrice,
inStock:
product.stock > 0 ||
(product.colorVariants || []).some((v) => Number(v.stock) > 0),
}),
]),
body,
})
);
sitemapPaths.push(`/products/${product.slug}/`);
writeLegacyFaRedirect(
`products/${product.slug}/index.html`,
`/products/${product.slug}/`,
`${pdpTitle} | Kikomiko`
);
}
write(
"search/index.html",
pageLayout({
title: `${dict.searchResults} | Kikomiko`,
dict,
megaMenu,
canonicalPath: "/search/",
noindex: true,
body: `
${esc(dict.searchResults)}
${esc(dict.searchResults)}
${productListingFilters({
megaMenu,
productCount: 0,
mode: "search",
clientMode: true,
})}
`,
})
);
writeLegacyFaRedirect("search/index.html", "/search/", `${dict.searchResults} | Kikomiko`);
write(
"cart/index.html",
pageLayout({
title: `${dict.cart} | Kikomiko`,
dict,
megaMenu,
canonicalPath: "/cart/",
noindex: true,
nofollow: true,
body: `
${esc(dict.cart)}
${esc(dict.cart)}
`,
})
);
writeLegacyFaRedirect("cart/index.html", "/cart/", `${dict.cart} | Kikomiko`);
write(
"checkout/index.html",
pageLayout({
title: `تسویه حساب | Kikomiko`,
dict,
megaMenu,
canonicalPath: "/checkout/",
noindex: true,
nofollow: true,
body: ``,
})
);
writeLegacyFaRedirect("checkout/index.html", "/checkout/", `تسویه حساب | Kikomiko`);
write(
"about/index.html",
pageLayout({
title: seoAboutTitle(),
description: seoAboutDescription(),
dict,
megaMenu,
canonicalPath: "/about/",
headExtra: jsonLdGraph([
organizationJsonLd(),
aboutPageJsonLd(),
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: "درباره کیکو میکو", path: "/about/" },
]),
]),
body: aboutPageBody(),
})
);
sitemapPaths.push("/about/");
writeLegacyFaRedirect("about/index.html", "/about/", "درباره کیکو میکو | کیکومیکو");
write(
"contact/index.html",
pageLayout({
title: seoContactTitle(),
description: seoContactDescription(),
dict,
megaMenu,
canonicalPath: "/contact/",
headExtra: jsonLdGraph([
organizationJsonLd(),
contactPageJsonLd(),
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: "تماس با ما", path: "/contact/" },
]),
]),
body: contactPageBody(),
})
);
sitemapPaths.push("/contact/");
writeLegacyFaRedirect("contact/index.html", "/contact/", "تماس با ما | کیکومیکو");
write(
"authenticity/index.html",
pageLayout({
title: seoAuthenticityTitle(),
description: seoAuthenticityDescription(),
dict,
megaMenu,
canonicalPath: "/authenticity/",
headExtra: jsonLdGraph([
organizationJsonLd(),
authenticityPageJsonLd(),
breadcrumbJsonLd([
{ name: "خانه", path: "/" },
{ name: "اصالت کالا", path: "/authenticity/" },
]),
]),
body: authenticityPageBody(),
})
);
sitemapPaths.push("/authenticity/");
writeLegacyFaRedirect(
"authenticity/index.html",
"/authenticity/",
"اصالت کالا | کیکومیکو"
);
write(
"404.html",
pageLayout({
title: "یافت نشد | Kikomiko",
dict,
megaMenu,
canonicalPath: "/404.html",
noindex: true,
body: ``,
})
);
write("robots.txt", ROBOTS_TXT);
write("sitemap.xml", buildSitemapXml(sitemapPaths, catalogUpdatedAt));
console.log(`Done. ${slice.length} product pages in ${DIST}`);
}
function categoryBody(
dict: ReturnType,
title: string,
rootSlug: string,
products: SiteProduct[],
sub: string | null,
megaMenu: MegaMenuRoot[],
landingKey?: string
) {
const landing = categoryLandingFor(landingKey);
const introLabel = sub || title;
const pageTitle = landing?.h1 ?? title;
const featured = (products.some((p) => p.featured)
? products.filter((p) => p.featured)
: products
).slice(0, 4);
const landingHtml = landing
? `
راهنمای خرید
${esc(landing.guideTitle)}
${esc(landing.intro)}
دستهبندی محصولات
محصولات پرفروش
راهنمای انتخاب رنگ و فینیش
${esc(landing.guide)}
${landingKey === "lips-make-up" ? `` : ""}
`
: "";
return `
${landingKey === "lips-make-up" ? "" : `
${esc(dict.collection)}
`}
${landingKey === "lips-make-up" ? "" : `
${esc(pageTitle)} `}
${landingKey === "lips-make-up" ? "" : `
${esc(seoCategoryIntro(introLabel))}
`}
${
sub && landingKey !== "lips-make-up"
? `
همهٔ دستهها
`
: ""
}
${landingKey === "lips-make-up" ? landingHtml : ""}
${productListingFilters({
megaMenu,
productCount: products.length,
categorySlug: rootSlug,
currentSub: sub,
mode: "category",
clientMode: true,
})}
${
products.length === 0
? `
`
: `
${products
.map((p) => listProductCard(p, { trailingSlash: true }))
.join("")}
`
}
${landingKey === "lips-make-up" ? "" : landingHtml}
`;
}
type ProductTypeGuide = {
slug: string;
rootSlug: "make-up";
typeFa: string;
h1: string;
title: string;
description: string;
intro: string;
needs: Array<{ title: string; text: string; query: string }>;
comparison: Array<{ title: string; text: string }>;
colour: string;
related: Array<{ label: string; href: string }>;
faq: Array<{ q: string; a: string }>;
};
const PRODUCT_TYPE_GUIDES: ProductTypeGuide[] = [
{
slug: "lip-gloss", rootSlug: "make-up", typeFa: "لیپ گلاس", h1: "خرید لیپ گلاس کیکو میلانو",
title: "خرید لیپ گلاس کیکو میلانو اصل | کیکومیکو", description: "خرید لیپ گلاس کیکو میلانو اصل با رنگها و فینیشهای متنوع در کیکومیکو.",
intro: "لیپ گلاسهای اصل KIKO Milano را در رنگها و فینیشهای مختلف ببینید؛ از برق ملایم روزانه تا رنگدهی چشمگیرتر.",
needs: [{ title: "براقترین گزینهها", text: "برای جلوهای درخشان و لبهای پرتر.", query: "lip gloss" }, { title: "روزانه", text: "بافت سبک و رنگ طبیعی برای هر روز.", query: "lip oil" }, { title: "رنگدهی بیشتر", text: "برای رنگ واضحتر و جلوهٔ برجستهتر.", query: "pigmented lip gloss" }],
comparison: [{ title: "Glossy", text: "برای ظاهر درخشان و حجیمتر." }, { title: "Matte", text: "برای رنگ پررنگتر و بدون درخشش." }],
colour: "پوست روشن: Nude یا Pink؛ پوست متوسط: Rose یا Mauve؛ تناژ گرم: Peach یا Brown. رنگ نهایی را با توجه به فینیش و شدت رنگ انتخاب کنید.",
related: [{ label: "رژ لب کیکو", href: "/category/make-up/sub/lips-make-up/" }, { label: "مداد لب کیکو", href: "/category/make-up/sub/lips-make-up/" }],
faq: [{ q: "برای استفاده روزانه کدام لیپ گلاس مناسبتر است؟", a: "بافتهای سبک با رنگ طبیعی معمولاً برای استفادهٔ روزانه انتخاب راحتتری هستند." }, { q: "لیپ گلاس مات است یا براق؟", a: "لیپ گلاس معمولاً جلوهای براق دارد؛ برای نتیجهٔ بدون درخشش، محصولات مات لب را بررسی کنید." }],
},
{
slug: "mascara", rootSlug: "make-up", typeFa: "ریمل", h1: "خرید ریمل کیکو میلانو",
title: "خرید ریمل کیکو میلانو اصل | کیکومیکو", description: "خرید ریمل کیکو میلانو اصل برای حجم، طول و فرمدهی مژهها.",
intro: "ریملهای KIKO Milano را برای حجمدهی، بلندکنندگی یا تفکیک مژهها مقایسه کنید.",
needs: [{ title: "حجمدهی", text: "برای مژههای پرتر و چشمگیرتر.", query: "volumizing mascara" }, { title: "بلندکننده", text: "برای کشیدهتر دیدهشدن مژهها.", query: "lengthening mascara" }, { title: "ماندگار", text: "برای استفادهٔ طولانی یا روزهای شلوغ.", query: "waterproof mascara" }],
comparison: [{ title: "Volume", text: "برای تراکم و جلوهٔ پرتر." }, { title: "Length", text: "برای ظاهر بلندتر و تفکیکشده." }],
colour: "مشکی برای تعریف کلاسیک چشم مناسب است؛ برای ظاهر نرمتر میتوانید سراغ قهوهای یا رنگهای کالکشنهای محدود بروید.",
related: [{ label: "خط چشم کیکو", href: "/category/make-up/sub/eyes-make-up/" }, { label: "مداد چشم کیکو", href: "/category/make-up/type/eye-pencil/" }],
faq: [{ q: "ریمل حجمدهنده برای چه کسی مناسب است؟", a: "برای کسانی که جلوهٔ پرتر و مژههای متراکمتر میخواهند." }, { q: "ریمل ضدآب چه زمانی بهتر است؟", a: "برای ماندگاری بیشتر در شرایط مرطوب یا استفادهٔ طولانی." }],
},
{
slug: "foundation", rootSlug: "make-up", typeFa: "کرم پودر", h1: "خرید کرم پودر کیکو میلانو",
title: "خرید کرم پودر کیکو میلانو اصل | کیکومیکو", description: "خرید کرم پودر کیکو میلانو اصل با پوشش و فینیشهای مختلف.",
intro: "کرم پودرهای KIKO Milano را بر اساس پوشش، فینیش و تناژ رنگ پوست انتخاب کنید.",
needs: [{ title: "پوشش طبیعی", text: "برای یکدستشدن سبک پوست.", query: "skin tint foundation" }, { title: "پوشش بیشتر", text: "برای نتیجهٔ کاملتر.", query: "full coverage foundation" }, { title: "ماندگار", text: "برای آرایش طولانیمدت.", query: "long wear foundation" }],
comparison: [{ title: "Natural / Glow", text: "برای پوست تازه و درخشان." }, { title: "Matte", text: "برای جلوهٔ ماتتر و کنترل برق پوست." }],
colour: "تناژ را با زیرتون پوست هماهنگ کنید: Rose برای زیرتون سرد، Gold برای گرم و Neutral برای حالت متعادل.",
related: [{ label: "کانسیلر کیکو", href: "/category/make-up/type/concealer/" }, { label: "پرایمر کیکو", href: "/category/make-up/sub/face-make-up/" }],
faq: [{ q: "فینیش مات یا درخشان؟", a: "مات برای ظاهر بدون برق و درخشان برای نتیجهٔ طبیعیتر و شادابتر مناسب است." }, { q: "چطور رنگ مناسب را انتخاب کنم؟", a: "شماره و زیرتون محصول را با رنگ گردن و زیرتون پوست مقایسه کنید." }],
},
{
slug: "concealer", rootSlug: "make-up", typeFa: "کانسیلر", h1: "خرید کانسیلر کیکو میلانو",
title: "خرید کانسیلر کیکو میلانو اصل | کیکومیکو", description: "خرید کانسیلر کیکو میلانو اصل برای پوشاندن تیرگی و یکدستکردن پوست.",
intro: "کانسیلرهای KIKO Milano را برای پوشاندن تیرگی زیر چشم، لکها و روشنترکردن نقاط صورت بررسی کنید.",
needs: [{ title: "زیر چشم", text: "برای ظاهر یکنواختتر زیر چشم.", query: "under eye concealer" }, { title: "پوشش بیشتر", text: "برای پوشاندن لک و ناهماهنگی رنگ.", query: "full coverage concealer" }, { title: "روشنکننده", text: "برای نقاط نور صورت.", query: "highlighting concealer" }],
comparison: [{ title: "Lightweight", text: "برای استفادهٔ روزانه و بافت سبک." }, { title: "Full coverage", text: "برای پوشش بیشتر و نتیجهٔ واضحتر." }],
colour: "برای زیر چشم، رنگی نزدیک به پوست یا کمی روشنتر انتخاب کنید؛ برای لک، نزدیکترین رنگ به کرم پودر معمولاً طبیعیتر دیده میشود.",
related: [{ label: "کرم پودر کیکو", href: "/category/make-up/type/foundation/" }, { label: "هایلایتر کیکو", href: "/category/make-up/sub/face-make-up/" }],
faq: [{ q: "کانسیلر روشنتر یا همرنگ پوست؟", a: "برای زیر چشم کمی روشنتر و برای پوشاندن لک، نزدیک به رنگ پوست انتخاب میشود." }, { q: "چطور از جمعشدن کانسیلر جلوگیری کنم؟", a: "با مقدار کم شروع کنید و آن را با ضربههای سبک محو کنید." }],
},
{
slug: "blush", rootSlug: "make-up", typeFa: "رژگونه", h1: "خرید رژگونه کیکو میلانو",
title: "خرید رژگونه کیکو میلانو اصل | کیکومیکو", description: "خرید رژگونه کیکو میلانو اصل با رنگها و فینیشهای متنوع.",
intro: "رژگونههای KIKO Milano برای رنگ طبیعی روزانه یا جلوهٔ پررنگتر مهمانی در دسترساند.",
needs: [{ title: "ظاهر طبیعی", text: "برای رنگ ملایم و قابلساخت.", query: "natural blush" }, { title: "درخشان", text: "برای جلوهٔ شادابتر پوست.", query: "glow blush" }, { title: "مات", text: "برای رنگ واضح و بدون برق.", query: "matte blush" }],
comparison: [{ title: "Glow", text: "برای پوست شاداب و درخشان." }, { title: "Matte", text: "برای رنگی نرم و بدون درخشش." }],
colour: "Pink و Rose معمولاً برای پوستهای روشنتر، و Peach یا Berry برای طیفهای گرمتر و متوسط انتخابهای محبوبی هستند.",
related: [{ label: "برنزر کیکو", href: "/category/make-up/sub/face-make-up/" }, { label: "هایلایتر کیکو", href: "/category/make-up/sub/face-make-up/" }],
faq: [{ q: "رژگونه مات یا درخشان؟", a: "مات ظاهر نرمتری میدهد و درخشان، نور و شادابی بیشتری به گونهها اضافه میکند." }, { q: "رنگ رژگونه را چطور انتخاب کنم؟", a: "به زیرتون پوست و شدت رنگی که در آرایش روزانه میپسندید توجه کنید." }],
},
{
slug: "eye-pencil", rootSlug: "make-up", typeFa: "مداد چشم", h1: "خرید مداد چشم کیکو میلانو",
title: "خرید مداد چشم کیکو میلانو اصل | کیکومیکو", description: "خرید مداد چشم کیکو میلانو اصل با رنگها و فینیشهای متنوع.",
intro: "مداد چشمهای KIKO Milano برای خط داخل چشم، خط مژه و ساخت آرایشهای محو یا دقیق انتخابهای متنوعی دارند.",
needs: [{ title: "خط دقیق", text: "برای تعریف خط مژه.", query: "eye pencil" }, { title: "اسموکی", text: "برای محوکردن و عمق بیشتر.", query: "smoky eye pencil" }, { title: "رنگی", text: "برای یک نقطهٔ رنگی در آرایش چشم.", query: "colour eye pencil" }],
comparison: [{ title: "Matte", text: "برای خطی واضح و کنترلشده." }, { title: "Metallic", text: "برای جلوهای نورانیتر روی چشم." }],
colour: "Black و Brown برای استفادهٔ روزانه و تعریف چشم مناسباند؛ رنگهای آبی، سبز یا بنفش برای آرایشهای رنگی انتخاب جذابی هستند.",
related: [{ label: "خط چشم کیکو", href: "/category/make-up/sub/eyes-make-up/" }, { label: "ریمل کیکو", href: "/category/make-up/type/mascara/" }],
faq: [{ q: "مداد چشم برای خط داخل چشم مناسب است؟", a: "بسته به مشخصات هر محصول، برخی مدادها برای خط داخلی و خارجی چشم طراحی شدهاند." }, { q: "فینیش مات یا متالیک؟", a: "مات برای آرایش کلاسیک و متالیک برای جلوهٔ نورانیتر مناسب است." }],
},
];
function productTypeGuideBody(
dict: ReturnType,
guide: ProductTypeGuide,
products: SiteProduct[],
megaMenu: MegaMenuRoot[]
) {
const featured = products.slice(0, 4);
return `
${productListingFilters({ megaMenu, productCount: products.length, categorySlug: guide.rootSlug, mode: "category", clientMode: true })}
${products.map((product) => listProductCard(product, { trailingSlash: true })).join("")}
راهنمای خرید
راهنمای خرید ${esc(guide.typeFa)} کیکو ${esc(guide.intro)}
فینیش مناسب شما کدام است؟ ${guide.comparison.map((item) => `
${esc(item.title)} ${esc(item.text)}
`).join("")}
چطور انتخاب کنیم؟ ${esc(guide.colour)}
سوالات متداول ${guide.faq.map((item) => `${esc(item.q)} ${esc(item.a)}
`).join("")}
`;
}
function categoryLandingFor(key?: string) {
const pages = {
"face-cleasing": {
h1: "خرید محصولات پاککننده صورت کیکو میلانو",
title: "خرید محصولات پاککننده صورت کیکو میلانو | کیکومیکو",
guideTitle: "راهنمای خرید پاککننده صورت کیکو میلانو",
intro: "پاککنندههای صورت کیکو میلانو برای پاککردن آرایش، آلودگی و چربی روزانه طراحی شدهاند تا پوست برای ادامهٔ روتین مراقبت آماده و تازه بماند.",
types: [
{ label: "میسلار واتر", query: "micellar" },
{ label: "ژل و فوم شوینده", query: "cleanser" },
{ label: "روغن و بالم پاککننده", query: "cleansing oil" },
{ label: "دستمال پاککننده آرایش", query: "makeup remover" },
],
guide: "برای پاکسازی روزانه، بافت محصول را متناسب با نوع پوست و میزان آرایش انتخاب کنید: میسلار واتر گزینهای سبک و سریع است، ژل و فوم برای شستوشوی صورت مناسباند و روغن یا بالم پاککننده به حلکردن آرایش مقاوم کمک میکنند. پس از پاکسازی، روتین آبرسانی پوست را ادامه دهید.",
},
"lips-make-up": {
h1: "خرید محصولات آرایش لب کیکو میلانو",
title: "خرید رژ لب و لیپ گلاس کیکو میلانو اصل | KIKO Milano",
guideTitle: "راهنمای خرید محصولات آرایش لب کیکو میلانو",
intro: "برای انتخاب آرایش لب کیکو میلانو، از رژ لبهای مات و کرمی تا لیپ گلاس، روغن لب و بالم لب، رنگ و بافتی را انتخاب کنید که با آرایش روزانه یا مهمانی شما هماهنگ باشد.",
types: [
{ label: "رژ لب کیکو", query: "lipstick" },
{ label: "لیپ گلاس کیکو", query: "lip gloss" },
{ label: "مداد لب کیکو", query: "lip pencil" },
{ label: "Lip Oil", query: "lip oil" },
{ label: "Lip Balm", query: "lip balm" },
],
guide: "برای نتیجهای مخملی و ماندگار، finish مات را انتخاب کنید؛ بافت کرمی برای رنگدهی راحت و حس نرم مناسب است و لیپ گلاس یا Lip Oil جلوهای براق و حجیمتر میدهد. برای انتخاب رنگ، تهرنگ پوست و استفادهٔ روزانه یا مهمانی را در نظر بگیرید و پیش از رژ لب، خط لب همخانواده را برای فرمدهی دقیقتر استفاده کنید.",
},
"eyes-make-up": {
h1: "خرید محصولات آرایش چشم کیکو میلانو",
title: "خرید محصولات آرایش چشم کیکو میلانو اصل | KIKO Milano",
guideTitle: "راهنمای خرید محصولات آرایش چشم کیکو میلانو",
intro: "از ریمل و خط چشم تا سایه، مداد چشم و محصولات ابرو، آرایش چشم کیکو میلانو برای ساختن ظاهرهای طبیعی روزانه تا چشمگیر شبانه طراحی شده است.",
types: [
{ label: "ریمل کیکو", query: "mascara" },
{ label: "خط چشم کیکو", query: "eyeliner" },
{ label: "سایه چشم کیکو", query: "eyeshadow" },
{ label: "مداد چشم کیکو", query: "eye pencil" },
{ label: "محصولات ابرو", query: "eyebrow" },
],
guide: "برای آرایش طبیعی، رنگهای نود و satin را انتخاب کنید؛ finish مات برای عمق دادن به پلک مناسب است و سایههای shimmer یا metallic نور بیشتری به چشم میدهند. خط چشم مدادی برای محو کردن آسانتر است و خط چشم مایع یا ماژیکی برای خطوط دقیق و ماندگار انتخاب بهتری است.",
},
} as const;
return key ? pages[key as keyof typeof pages] : undefined;
}
try {
main();
} catch (err) {
console.error(err);
process.exit(1);
}