From fbf8f35f4717c7d6585edbdf555665aa81838d46 Mon Sep 17 00:00:00 2001 From: ddaoxuan Date: Wed, 6 Nov 2024 21:48:12 +0100 Subject: [PATCH] chore: sync scripts --- .../app/api/feed/sync/route.ts | 26 +- .../components/demo-mode-alert.tsx | 2 +- .../shopify-algolia/lib/algolia/client.ts | 164 +++---- .../shopify-algolia/lib/shopify/client.ts | 52 +- .../shopify-algolia/lib/shopify/normalize.ts | 14 +- .../shopify/queries/collection.storefront.ts | 8 +- .../shopify/types/storefront.generated.d.ts | 6 +- starters/shopify-algolia/lib/shopify/utils.ts | 7 + starters/shopify-algolia/package.json | 9 +- starters/shopify-algolia/scripts/sync.ts | 122 +++++ starters/shopify-algolia/tsup.config.ts | 13 + starters/shopify-algolia/types/index.ts | 5 + .../shopify-algolia/utils/enrich-product.ts | 124 +++-- starters/shopify-algolia/utils/opt-in.ts | 6 +- starters/shopify-algolia/yarn.lock | 463 +++++++++++++++++- .../app/api/feed/sync/route.ts | 16 +- .../components/demo-mode-alert.tsx | 2 +- .../shopify-meilisearch/lib/shopify/client.ts | 13 +- .../lib/shopify/normalize.ts | 5 +- .../shopify/types/storefront.generated.d.ts | 6 +- .../shopify-meilisearch/lib/shopify/utils.ts | 7 + starters/shopify-meilisearch/package.json | 3 +- starters/shopify-meilisearch/scripts/sync.ts | 114 ++--- starters/shopify-meilisearch/types/index.ts | 5 + .../utils/enrich-product.ts | 122 +++-- starters/shopify-meilisearch/utils/opt-in.ts | 8 +- starters/shopify-meilisearch/yarn.lock | 47 ++ 27 files changed, 1068 insertions(+), 301 deletions(-) create mode 100644 starters/shopify-algolia/lib/shopify/utils.ts create mode 100644 starters/shopify-algolia/scripts/sync.ts create mode 100644 starters/shopify-algolia/tsup.config.ts create mode 100644 starters/shopify-meilisearch/lib/shopify/utils.ts diff --git a/starters/shopify-algolia/app/api/feed/sync/route.ts b/starters/shopify-algolia/app/api/feed/sync/route.ts index de13f5a3..96b13a50 100644 --- a/starters/shopify-algolia/app/api/feed/sync/route.ts +++ b/starters/shopify-algolia/app/api/feed/sync/route.ts @@ -1,9 +1,12 @@ -import type { PlatformProduct } from "lib/shopify/types" import { env } from "env.mjs" import { compareHmac } from "utils/compare-hmac" -import { enrichProduct } from "utils/enrich-product" +import { ProductEnrichmentBuilder } from "utils/enrich-product" import { deleteCategories, deleteProducts, updateCategories, updateProducts } from "lib/algolia" import { getCollection, getHierarchicalCollections, getProduct } from "lib/shopify" +import { makeShopifyId } from "lib/shopify/utils" +import { HIERARCHICAL_SEPARATOR } from "constants/index" +import { isOptIn } from "utils/opt-in" +import { getAllProductReviews } from "lib/reviews" type SupportedTopic = "products/update" | "products/delete" | "products/create" | "collections/update" | "collections/delete" | "collections/create" @@ -55,7 +58,7 @@ async function handleCollectionTopics(topic: SupportedTopic, { id }: RecordFiltering, searching, and adding to cart is disabled.

To enable,{" "} - + setup environment variables
diff --git a/starters/shopify-algolia/lib/algolia/client.ts b/starters/shopify-algolia/lib/algolia/client.ts index 2bcbcaf8..51c99b53 100644 --- a/starters/shopify-algolia/lib/algolia/client.ts +++ b/starters/shopify-algolia/lib/algolia/client.ts @@ -1,4 +1,5 @@ import { + type BatchProps, algoliasearch, type BrowseProps, type DeleteObjectsOptions, @@ -7,139 +8,110 @@ import { type SearchMethodParams, type SearchResponse, type SearchSingleIndexProps, -} from "algoliasearch"; +} from "algoliasearch" -import { env } from "env.mjs"; +import { env } from "env.mjs" -import { FilterBuilder } from "./filter-builder"; +import { FilterBuilder } from "./filter-builder" const algoliaClient = (args: { applicationId: string; apiKey: string }) => { - return algoliasearch(args.applicationId, args.apiKey); -}; + return algoliasearch(args.applicationId, args.apiKey) +} export const algolia = (args: { applicationId: string; apiKey: string }) => { - const client = algoliaClient(args); - const recommendationClient = client.initRecommend(); + const client = algoliaClient(args) + const recommendationClient = client.initRecommend() return { - search: async >( - args: SearchSingleIndexProps - ) => search(args, client), - getAllResults: async >(args: BrowseProps) => - getAllResults(client, args), - update: async (args: PartialUpdateObjectsOptions) => - updateObjects(args, client), + search: async >(args: SearchSingleIndexProps) => search(args, client), + getAllResults: async >(args: BrowseProps) => getAllResults(client, args), + update: async (args: PartialUpdateObjectsOptions) => updateObjects(args, client), + batchUpdate: async (args: BatchProps) => batchUpdate(args, client), delete: async (args: DeleteObjectsOptions) => deleteObjects(args, client), - create: async (args: PartialUpdateObjectsOptions) => - createObjects(args, client), - multiSearch: async >( - args: SearchMethodParams - ) => multiSearch(args, client), - getRecommendations: async (args: GetRecommendationsParams) => - getRecommendations(recommendationClient, args), + create: async (args: PartialUpdateObjectsOptions) => createObjects(args, client), + multiSearch: async >(args: SearchMethodParams) => multiSearch(args, client), + getRecommendations: async (args: GetRecommendationsParams) => getRecommendations(recommendationClient, args), filterBuilder: () => new FilterBuilder(), mapIndexToSort, - }; -}; + } +} -const search = async >( - args: SearchSingleIndexProps, - client: ReturnType -) => { - return client.searchSingleIndex(args); -}; +const search = async >(args: SearchSingleIndexProps, client: ReturnType) => { + return client.searchSingleIndex(args) +} // agregator as temp fix for now -const getAllResults = async >( - client: ReturnType, - args: BrowseProps -) => { - const allHits: T[] = []; - let totalPages: number; - let currentPage = 0; +const getAllResults = async >(client: ReturnType, args: BrowseProps) => { + const allHits: T[] = [] + let totalPages: number + let currentPage = 0 do { - const { hits, nbPages } = await client.browseObjects({ + const { hits, nbPages } = await client.browse({ ...args, browseParams: { ...args.browseParams, hitsPerPage: 1000, + page: currentPage, }, - aggregator: () => null, - }); - allHits.push(...hits); - totalPages = nbPages || 0; - currentPage++; - } while (currentPage < totalPages); - - return { hits: allHits, totalPages }; -}; - -const updateObjects = async ( - args: PartialUpdateObjectsOptions, - client: ReturnType -) => { - return client.partialUpdateObjects(args); -}; - -const deleteObjects = async ( - args: DeleteObjectsOptions, - client: ReturnType -) => { - return client.deleteObjects(args); -}; - -const createObjects = async ( - args: PartialUpdateObjectsOptions, - client: ReturnType -) => { + }) + allHits.push(...hits) + totalPages = nbPages || 0 + currentPage++ + } while (currentPage < totalPages) + + return { hits: allHits, totalPages } +} + +const batchUpdate = async (args: BatchProps, client: ReturnType) => { + return client.batch(args) +} + +const updateObjects = async (args: PartialUpdateObjectsOptions, client: ReturnType) => { + return client.partialUpdateObjects(args) +} + +const deleteObjects = async (args: DeleteObjectsOptions, client: ReturnType) => { + return client.deleteObjects(args) +} + +const createObjects = async (args: PartialUpdateObjectsOptions, client: ReturnType) => { return client.partialUpdateObjects({ ...args, createIfNotExists: true, - }); -}; - -const multiSearch = async >( - args: SearchMethodParams, - client: ReturnType -) => { - return client.search(args) as Promise<{ results: SearchResponse[] }>; -}; - -const getRecommendations = async ( - client: ReturnType["initRecommend"]>, - args: GetRecommendationsParams -) => { - return client.getRecommendations(args); -}; - -export type SortType = - | "minPrice:desc" - | "minPrice:asc" - | "avgRating:desc" - | "updatedAtTimestamp:asc" - | "updatedAtTimestamp:desc"; + }) +} + +const multiSearch = async >(args: SearchMethodParams, client: ReturnType) => { + return client.search(args) as Promise<{ results: SearchResponse[] }> +} + +const getRecommendations = async (client: ReturnType["initRecommend"]>, args: GetRecommendationsParams) => { + return client.getRecommendations(args) +} + +export type SortType = "minPrice:desc" | "minPrice:asc" | "avgRating:desc" | "updatedAtTimestamp:asc" | "updatedAtTimestamp:desc" const mapIndexToSort = (index: string, sortOption: SortType) => { switch (sortOption) { case "minPrice:desc": - return `${index}_price_desc`; + return `${index}_price_desc` case "minPrice:asc": - return `${index}_price_asc`; + return `${index}_price_asc` case "avgRating:desc": - return `${index}_rating_desc`; + return `${index}_rating_desc` case "updatedAtTimestamp:asc": - return `${index}_updated_asc`; + return `${index}_updated_asc` case "updatedAtTimestamp:desc": - return `${index}_updated_desc`; + return `${index}_updated_desc` default: - return index; + return index } -}; +} export const searchClient: ReturnType = algolia({ applicationId: env.ALGOLIA_APP_ID || "", // Make sure write api key never leaks to the client apiKey: env.ALGOLIA_WRITE_API_KEY || "", -}); +}) diff --git a/starters/shopify-algolia/lib/shopify/client.ts b/starters/shopify-algolia/lib/shopify/client.ts index 1a1028cf..f1da0488 100644 --- a/starters/shopify-algolia/lib/shopify/client.ts +++ b/starters/shopify-algolia/lib/shopify/client.ts @@ -13,7 +13,7 @@ import { getMenuQuery, type MenuQuery } from "./queries/menu.storefront" import { getPageQuery, getPagesQuery } from "./queries/page.storefront" import { getLatestProductFeedQuery } from "./queries/product-feed.admin" import { getAdminProductQuery, getProductStatusQuery } from "./queries/product.admin" -import { getProductQuery, getProductsByHandleQuery } from "./queries/product.storefront" +import { getProductQuery, getProductsByHandleQuery, getProductsQuery } from "./queries/product.storefront" import type { LatestProductFeedsQuery, @@ -57,6 +57,7 @@ import { } from "./types" import { env } from "env.mjs" +import { cleanShopifyId, makeShopifyId } from "./utils" interface CreateShopifyClientProps { storeDomain: string @@ -105,6 +106,8 @@ export function createShopifyClient({ storefrontAccessToken, adminAccessToken, s updateUser: async (accessToken: string, input: Omit) => updateUser(client!, accessToken, input), createUserAccessToken: async (input: Pick) => createUserAccessToken(client!, input), getHierarchicalCollections: async (handle: string, depth?: number) => getHierarchicalCollections(client!, handle, depth), + getAllProducts: async () => getAllProducts(client!), + getAllCollections: async () => getAllCollections(client!), } } @@ -116,7 +119,7 @@ async function getMenu(client: StorefrontApiClient, handle: string = "main-menu" return { items: mappedItems || [] } } -async function getHierarchicalCollections(client: StorefrontApiClient, handle: string, depth = 3): Promise { +async function getHierarchicalCollections(client: StorefrontApiClient, handle: string, depth = 3) { const query = getMenuQuery(depth) const response = await client.request(query, { variables: { handle } }) const mappedItems = response.data?.menu.items.filter((item) => item.resource?.__typename === "Collection") @@ -127,7 +130,7 @@ async function getHierarchicalCollections(client: StorefrontApiClient, handle: s } async function getProduct(client: StorefrontApiClient, id: string): Promise { - const response = await client.request(getProductQuery, { variables: { id } }) + const response = await client.request(getProductQuery, { variables: { id: makeShopifyId(id, "Product") } }) const product = response.data?.product return normalizeProduct(product) @@ -176,7 +179,7 @@ async function getAllPages(client: StorefrontApiClient): Promise { - const status = await client.request(getProductStatusQuery, { variables: { id } }) + const status = await client.request(getProductStatusQuery, { variables: { id: makeShopifyId(id, "Product") } }) return status.data?.product } @@ -224,7 +227,7 @@ async function getCollection(client: StorefrontApiClient, handle: string): Promi } async function getCollectionById(client: StorefrontApiClient, id: string): Promise { - const collection = await client.request(getCollectionByIdQuery, { variables: { id } }) + const collection = await client.request(getCollectionByIdQuery, { variables: { id: makeShopifyId(id, "Collection") } }) return normalizeCollection(collection.data?.collection) } @@ -255,7 +258,7 @@ async function updateUser(client: StorefrontApiClient, customerAccessToken: stri async function getAdminProduct(client: AdminApiClient, id: string) { const response = await client.request(getAdminProductQuery, { - variables: { id: id.startsWith("gid://shopify/Product/") ? id : `gid://shopify/Product/${id}` }, + variables: { id: makeShopifyId(id, "Product") }, }) if (!response.data?.product) return null @@ -266,6 +269,43 @@ async function getAdminProduct(client: AdminApiClient, id: string) { return normalizeProduct({ ...response.data?.product, variants }) } +async function getAllProducts(client: StorefrontApiClient, limit: number = 250): Promise { + const products: PlatformProduct[] = [] + let hasNextPage = true + let cursor: string | null = null + + while (hasNextPage) { + const response = await client.request(getProductsQuery, { + variables: { numProducts: limit, cursor }, + }) + + const fetchedProducts = response.data?.products?.edges || [] + products.push(...fetchedProducts.map((edge) => normalizeProduct(edge.node))) + + hasNextPage = response.data?.products?.pageInfo?.hasNextPage || false + cursor = hasNextPage ? response.data?.products?.pageInfo?.endCursor : null + } + + return products.map((product) => ({ ...product, id: cleanShopifyId(product.id, "Product") })) +} + +async function getAllCollections(client: StorefrontApiClient, limit?: number) { + const collections: PlatformCollection[] = [] + let hasNextPage = true + let cursor: string | null = null + + while (hasNextPage) { + const response = await client.request(getCollectionsQuery, { variables: { first: limit, after: cursor } }) + const fetchedCollections = response.data?.collections?.edges || [] + collections.push(...fetchedCollections.map((edge) => normalizeCollection(edge.node))) + + hasNextPage = response.data?.collections?.pageInfo?.hasNextPage || false + cursor = hasNextPage ? response?.data?.collections?.pageInfo?.endCursor : null + } + + return collections.map((collection) => ({ ...collection, id: cleanShopifyId(collection.id, "Collection") })) +} + export const storefrontClient = createShopifyClient({ storeDomain: env.SHOPIFY_STORE_DOMAIN || "", storefrontAccessToken: env.SHOPIFY_STOREFRONT_ACCESS_TOKEN || "", diff --git a/starters/shopify-algolia/lib/shopify/normalize.ts b/starters/shopify-algolia/lib/shopify/normalize.ts index 7142b2aa..dce8fa3b 100644 --- a/starters/shopify-algolia/lib/shopify/normalize.ts +++ b/starters/shopify-algolia/lib/shopify/normalize.ts @@ -1,12 +1,13 @@ import { SingleCartQuery, SingleCollectionQuery, SingleProductQuery } from "./types/storefront.generated" import type { PlatformCart, PlatformCartItem, PlatformCollection, PlatformProduct } from "./types" +import { cleanShopifyId } from "./utils" export function normalizeProduct(product: SingleProductQuery["product"]): PlatformProduct | null { if (!product) return null const { id, handle, title, description, vendor, descriptionHtml, options, priceRange, variants, featuredImage, images, tags, updatedAt, createdAt, collections, seo } = product return { - id, + id: cleanShopifyId(id, "Product"), handle, title, description, @@ -46,5 +47,14 @@ export function normalizeCollection(collection: SingleCollectionQuery["collectio if (!collection) return null const { id, handle, title, descriptionHtml, seo, image, updatedAt, description } = collection - return { id, handle, title, descriptionHtml, seo, image, updatedAt, description } + return { + id: cleanShopifyId(id, "Collection"), + handle, + title, + descriptionHtml, + seo, + image, + updatedAt, + description, + } } diff --git a/starters/shopify-algolia/lib/shopify/queries/collection.storefront.ts b/starters/shopify-algolia/lib/shopify/queries/collection.storefront.ts index 40a78a78..144aba2a 100644 --- a/starters/shopify-algolia/lib/shopify/queries/collection.storefront.ts +++ b/starters/shopify-algolia/lib/shopify/queries/collection.storefront.ts @@ -19,13 +19,17 @@ export const getCollectionQuery = `#graphql ` export const getCollectionsQuery = `#graphql - query Collections($limit: Int = 250) { - collections(first: $limit, sortKey: TITLE) { + query Collections($first: Int = 250, $after: String) { + collections(first: $first, after: $after, sortKey: TITLE) { edges { node { ...singleCollection } } + pageInfo { + hasNextPage + endCursor + } } } ${collectionFragment} diff --git a/starters/shopify-algolia/lib/shopify/types/storefront.generated.d.ts b/starters/shopify-algolia/lib/shopify/types/storefront.generated.d.ts index 8c2c6d95..94739202 100644 --- a/starters/shopify-algolia/lib/shopify/types/storefront.generated.d.ts +++ b/starters/shopify-algolia/lib/shopify/types/storefront.generated.d.ts @@ -544,7 +544,8 @@ export type SingleCollectionQuery = { } export type CollectionsQueryVariables = StorefrontTypes.Exact<{ - limit?: StorefrontTypes.InputMaybe + first?: StorefrontTypes.InputMaybe + after?: StorefrontTypes.InputMaybe }> export type CollectionsQuery = { @@ -555,6 +556,7 @@ export type CollectionsQuery = { seo: Pick } }> + pageInfo: Pick } } @@ -716,7 +718,7 @@ interface GeneratedQueryTypes { return: SingleCollectionQuery variables: SingleCollectionQueryVariables } - "#graphql\n query Collections($limit: Int = 250) {\n collections(first: $limit, sortKey: TITLE) {\n edges {\n node {\n ...singleCollection\n }\n }\n }\n }\n #graphql\n fragment singleCollection on Collection {\n handle\n image {\n ...singleImage\n }\n title\n descriptionHtml\n id\n description\n seo {\n ...seo\n }\n updatedAt\n }\n #graphql\n fragment seo on SEO {\n description\n title\n }\n\n #graphql\n fragment singleImage on Image {\n url\n altText\n width\n height\n }\n\n\n": { + "#graphql\n query Collections($first: Int = 250, $after: String) {\n collections(first: $first, after: $after, sortKey: TITLE) {\n edges {\n node {\n ...singleCollection\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n #graphql\n fragment singleCollection on Collection {\n handle\n image {\n ...singleImage\n }\n title\n descriptionHtml\n id\n description\n seo {\n ...seo\n }\n updatedAt\n }\n #graphql\n fragment seo on SEO {\n description\n title\n }\n\n #graphql\n fragment singleImage on Image {\n url\n altText\n width\n height\n }\n\n\n": { return: CollectionsQuery variables: CollectionsQueryVariables } diff --git a/starters/shopify-algolia/lib/shopify/utils.ts b/starters/shopify-algolia/lib/shopify/utils.ts new file mode 100644 index 00000000..3349ec83 --- /dev/null +++ b/starters/shopify-algolia/lib/shopify/utils.ts @@ -0,0 +1,7 @@ +export function makeShopifyId(id: string, type: "Product" | "Collection") { + return id.startsWith("gid://shopify/") ? id : `gid://shopify/${type}/${id}` +} + +export function cleanShopifyId(id: string, type: "Product" | "Collection") { + return id.replace(`gid://shopify/${type}/`, "") +} diff --git a/starters/shopify-algolia/package.json b/starters/shopify-algolia/package.json index 16cbe7ca..c6314f9e 100644 --- a/starters/shopify-algolia/package.json +++ b/starters/shopify-algolia/package.json @@ -18,7 +18,9 @@ "postinstall": "npx patch-package -y", "generate-bloom-filter": "ts-node redirects/generate-bloom-filter.ts", "codegen": "graphql-codegen && graphql-codegen -p admin", - "coupling-graph": "npx madge --extensions js,jsx,ts,tsx,css,md,mdx ./ --exclude '.next|tailwind.config.js|reset.d.ts|prettier.config.js|postcss.config.js|playwright.config.ts|next.config.js|next-env.d.ts|instrumentation.ts|e2e/|README.md|.storybook/|.eslintrc.js' --image graph.svg" + "coupling-graph": "npx madge --extensions js,jsx,ts,tsx,css,md,mdx ./ --exclude '.next|tailwind.config.js|reset.d.ts|prettier.config.js|postcss.config.js|playwright.config.ts|next.config.js|next-env.d.ts|instrumentation.ts|e2e/|README.md|.storybook/|.eslintrc.js' --image graph.svg", + "sync": "yarn build:scripts && node scripts/dist/sync.mjs", + "build:scripts": "rimraf scripts/dist && tsup scripts/ -d scripts/dist" }, "dependencies": { "@ai-sdk/openai": "^0.0.71", @@ -90,6 +92,7 @@ "@typescript-eslint/eslint-plugin": "^7.3.1", "@typescript-eslint/parser": "^7.3.1", "@vercel/style-guide": "^5.0.0", + "dotenv": "^16.4.5", "eslint": "8.54.0", "eslint-config-next": "14.0.3", "eslint-config-prettier": "^9.0.0", @@ -100,14 +103,18 @@ "eslint-plugin-tailwindcss": "^3.13.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "lodash.isequal": "^4.5.0", + "lodash.omit": "^4.5.0", "patch-package": "^8.0.0", "prettier": "3.2.5", "prettier-plugin-tailwindcss": "^0.5.12", + "rimraf": "^6.0.1", "server-only": "^0.0.1", "tailwind-merge": "^2.2.2", "tailwindcss": "^3.4.1", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", + "tsup": "^8.3.5", "typescript": "5.4.3", "zod": "^3.22.4" } diff --git a/starters/shopify-algolia/scripts/sync.ts b/starters/shopify-algolia/scripts/sync.ts new file mode 100644 index 00000000..5d1bc5c1 --- /dev/null +++ b/starters/shopify-algolia/scripts/sync.ts @@ -0,0 +1,122 @@ +import isEqual from "lodash.isequal" +import omit from "lodash.omit" + +import { HIERARCHICAL_SEPARATOR } from "../constants/index" +import { storefrontClient } from "../lib/shopify/client" +import type { PlatformProduct } from "../lib/shopify/types" +import { searchClient } from "../lib/algolia/client" +import { env } from "../env.mjs" +import { isOptIn } from "utils/opt-in" +import { reviewsClient } from "lib/reviews/client" +import { ProductEnrichmentBuilder, buildCategoryMap } from "utils/enrich-product" + +async function sync() { + console.log("🚀 Starting sync process...") + + try { + console.log("📦 Fetching products from Shopify...") + const allProducts = await storefrontClient.getAllProducts() + console.log(`✓ Found ${allProducts.length} products`) + + console.log("📑 Fetching categories from Shopify...") + const allCategories = await storefrontClient.getAllCollections() + console.log(`✓ Found ${allCategories.length} categories`) + + if (!allProducts.length || !allCategories.length) { + console.warn("⚠️ No products or categories found, aborting sync") + return + } + + console.log("🌳 Fetching hierarchical collections...") + const hierarchicalCategories = await storefrontClient.getHierarchicalCollections(env.SHOPIFY_HIERARCHICAL_NAV_HANDLE!) + console.log(`✓ Found ${hierarchicalCategories.items.length} hierarchical categories`) + + console.log("🔄 Enriching products with hierarchical data...") + const categoryMap = buildCategoryMap(hierarchicalCategories.items) + const reviews = isOptIn("reviews") ? await reviewsClient.getAllProductReviews() : [] + + const enrichedProducts = allProducts.map((product) => + new ProductEnrichmentBuilder(product).withHierarchicalCategories(hierarchicalCategories.items, HIERARCHICAL_SEPARATOR).withReviews(reviews).build() + ) + + console.log("📥 Fetching current Algolia indices...") + const { hits: allIndexProducts } = await searchClient.getAllResults({ + indexName: env.ALGOLIA_PRODUCTS_INDEX, + browseParams: {}, + }) + + console.log(`✓ Found ${allIndexProducts.length} products in Algolia`) + + const { hits: allIndexCategories } = await searchClient.getAllResults({ + indexName: env.ALGOLIA_CATEGORIES_INDEX, + }) + + console.log(`✓ Found ${allIndexCategories.length} categories in Algolia`) + + console.log("🔍 Calculating differences...") + const deltaProducts = calculateProductDelta(enrichedProducts, allIndexProducts) + const deltaCategories = calculateCategoryDelta(allCategories, allIndexCategories) + + await updateAlgoliaDocuments(env.ALGOLIA_PRODUCTS_INDEX, deltaProducts, "products") + await updateAlgoliaDocuments(env.ALGOLIA_CATEGORIES_INDEX, deltaCategories, "categories") + + if (deltaProducts.length === 0 && deltaCategories.length === 0) { + console.log("✨ Nothing to sync, looks like you're all set!") + return + } + + console.log("🎉 Sync completed successfully!") + } catch (error) { + console.error("❌ Error during sync:", error instanceof Error ? error.message : error) + throw error + } +} + +async function updateAlgoliaDocuments>(indexName: string, documents: T[], entityName: string) { + if (documents.length === 0) return + + console.log(`📤 Updating ${documents.length} ${entityName} in Algolia...`) + await searchClient.batchUpdate({ + indexName, + batchWriteParams: { + requests: documents.map((doc) => ({ + action: doc.objectID ? "partialUpdateObjectNoCreate" : "addObject", + body: doc, + })), + }, + }) +} + +function calculateProductDelta(enrichedProducts: PlatformProduct[], allIndexProducts: any[]) { + const allIndexProductsMap = new Map(allIndexProducts.map((product) => [product.id, product])) + + return enrichedProducts.reduce>((acc, product) => { + const existingProduct = allIndexProductsMap.get(product.id) + if (!existingProduct || !isEqual(omit(product, ["objectID"]), omit(existingProduct, ["objectID"]))) { + acc.push(existingProduct ? { ...product, objectID: existingProduct.objectID } : product) + } + return acc + }, []) +} + +function calculateCategoryDelta(categories: PlatformProduct["collections"], allIndexCategories: any[]) { + const allIndexCategoriesMap = new Map(allIndexCategories.map((category) => [category.id, category])) + + return categories.reduce>((acc, category) => { + const existingCategory = allIndexCategoriesMap.get(category.id) + if (!existingCategory || !isEqual(omit(category, ["objectID"]), omit(existingCategory, ["objectID"]))) { + acc.push(existingCategory ? { ...category, objectID: existingCategory.objectID } : category) + } + return acc + }, []) +} + +sync() + .then(() => { + console.log("👋 Sync process finished") + process.exit(0) + }) + .catch((error) => { + console.error("💥 Sync process failed:", error) + process.exit(1) + }) diff --git a/starters/shopify-algolia/tsup.config.ts b/starters/shopify-algolia/tsup.config.ts new file mode 100644 index 00000000..abdf4772 --- /dev/null +++ b/starters/shopify-algolia/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup" +import dotenv from "dotenv" + +dotenv.config({ path: ".env.local" }) + +const envVariables = Object.fromEntries(Object.entries(process.env).map(([key, value]) => [`process.env.${key}`, JSON.stringify(value)])) + +export default defineConfig({ + format: ["esm"], + clean: true, + target: "node16", + define: envVariables, +}) diff --git a/starters/shopify-algolia/types/index.ts b/starters/shopify-algolia/types/index.ts index 54f38f5d..032ccca2 100644 --- a/starters/shopify-algolia/types/index.ts +++ b/starters/shopify-algolia/types/index.ts @@ -7,4 +7,9 @@ export type CommerceProduct = PlatformProduct & { avgRating?: number totalReviews?: number reviewsSummary?: string + hierarchicalCategories?: { + lvl0?: string[] + lvl1?: string[] + lvl2?: string[] + } } diff --git a/starters/shopify-algolia/utils/enrich-product.ts b/starters/shopify-algolia/utils/enrich-product.ts index 18cefe35..fe8c0405 100644 --- a/starters/shopify-algolia/utils/enrich-product.ts +++ b/starters/shopify-algolia/utils/enrich-product.ts @@ -1,61 +1,125 @@ import { generateImageCaption } from "lib/replicate" +import { Review } from "lib/reviews/types" import type { PlatformImage, PlatformMenu, PlatformProduct } from "lib/shopify/types" +import { CommerceProduct } from "types" +import { isOptIn } from "./opt-in" /* * Enrich product by attaching hierarchical categories to it * Takes in all tags, and tries to find hierarchy against it - * Currently just shopify focused */ -export const enrichProduct = async (product: PlatformProduct, collections: PlatformMenu["items"]) => { - const categoryMap = buildCategoryMap(collections) - const images = await generateProductAltTags(product) - const hierarchicalCategories = generateHierarchicalCategories(product.tags, categoryMap) +export class ProductEnrichmentBuilder { + private product: CommerceProduct - return { - ...product, - images: images.filter(Boolean), - hierarchicalCategories, + constructor(baseProduct: PlatformProduct) { + this.product = baseProduct + } + + withHierarchicalCategories(collections: PlatformMenu["items"], separator: string): this { + const categoryMap = buildCategoryMap(collections) + + if (!categoryMap.size) { + return this + } + + this.product = { + ...this.product, + hierarchicalCategories: generateHierarchicalCategories(this.product.tags, categoryMap, separator), + } + return this + } + + withReviews(allReviews: Review[]): this { + if (!isOptIn("reviews")) { + return this + } + + const productReviews = allReviews.filter((review) => review.product_handle === this.product.handle) + + if (productReviews.length) { + const avgRating = productReviews.reduce((acc, review) => acc + review.rating, 0) / productReviews.length || 0 + + this.product = { + ...this.product, + avgRating, + totalReviews: productReviews.length, + } + } + return this + } + + async withAltTags(): Promise { + if (!isOptIn("altTags")) { + return this + } + + try { + const images = await generateProductAltTags(this.product) + this.product = { + ...this.product, + images: images.filter(Boolean), + } + } catch (e) { + console.error(e) + } + return this + } + + build(): CommerceProduct { + return this.product } } -function buildCategoryMap(categories: PlatformMenu["items"]) { +async function generateProductAltTags(product: PlatformProduct): Promise<(PlatformImage | undefined)[]> { + try { + const altTagAwareImages = await Promise.all(product.images?.slice(0, 1).map(mapper).filter(Boolean)) + return [...altTagAwareImages, ...product.images?.slice(1)?.filter(Boolean)] || [] + } catch (e) { + console.error(e) + return product.images // graceful exit + } +} + +async function mapper(image: PlatformImage) { + const output = await generateImageCaption(image.url) + return { ...image, altText: output?.replace("Caption:", "") || "" } +} + +export function buildCategoryMap(items: PlatformMenu["items"]): Map { const categoryMap = new Map() - function traverse(items: PlatformMenu["items"], path: string[]) { + const traverse = (items: PlatformMenu["items"], path: string[]) => { for (const item of items) { - const newPath = [...path, item.resource!.handle] - categoryMap.set(item.resource!.handle, newPath) + const newPath = [...path, item.resource?.handle || ""] + categoryMap.set(item.resource?.handle || "", newPath) if (item.items && item.items.length > 0) { traverse(item.items, newPath) } } } - traverse(categories, []) - + traverse(items, []) return categoryMap } -function generateHierarchicalCategories(tags: PlatformProduct["tags"], categoryMap: Map) { - const hierarchicalCategories: Record<"lvl0" | "lvl1" | "lvl2", string[]> = { lvl0: [], lvl1: [], lvl2: [] } - - if (!tags.length || !categoryMap.size) return hierarchicalCategories +export function generateHierarchicalCategories(collections: PlatformProduct["tags"], categoryMap: Map, separator: string = " > ") { + const hierarchicalCategories: { lvl0: string[]; lvl1: string[]; lvl2: string[] } = { lvl0: [], lvl1: [], lvl2: [] } - tags.forEach((tag) => { + collections.forEach((tag) => { const path = categoryMap.get(tag) if (path) { if (path.length > 0 && !hierarchicalCategories.lvl0.includes(path[0])) { hierarchicalCategories.lvl0.push(path[0]) } if (path.length > 1) { - const lvl1Path = path.slice(0, 2).join(" > ") + const lvl1Path = path.slice(0, 2).join(separator) if (!hierarchicalCategories.lvl1.includes(lvl1Path)) { hierarchicalCategories.lvl1.push(lvl1Path) } } if (path.length > 2) { - const lvl2Path = path.slice(0, 3).join(" > ") + const lvl2Path = path.slice(0, 3).join(separator) if (!hierarchicalCategories.lvl2.includes(lvl2Path)) { hierarchicalCategories.lvl2.push(lvl2Path) } @@ -65,19 +129,3 @@ function generateHierarchicalCategories(tags: PlatformProduct["tags"], categoryM return hierarchicalCategories } - -async function generateProductAltTags(product: PlatformProduct): Promise<(PlatformImage | undefined)[]> { - try { - const altTagAwareImages = await Promise.all(product?.images?.slice(0, 1).map(mapper).filter(Boolean)) - return [...altTagAwareImages, ...product?.images?.slice(1)?.filter(Boolean)] || [] - } catch (e) { - console.error(e) - return product.images // graceful exit - } - - async function mapper(image: PlatformImage) { - const output = await generateImageCaption(image.url) - - return { ...image, altText: output?.replace("Caption:", "") || "" } - } -} diff --git a/starters/shopify-algolia/utils/opt-in.ts b/starters/shopify-algolia/utils/opt-in.ts index 417cd122..bd6ae0b4 100644 --- a/starters/shopify-algolia/utils/opt-in.ts +++ b/starters/shopify-algolia/utils/opt-in.ts @@ -1,6 +1,6 @@ import { env } from "env.mjs" -type Feature = "reviews" | "ai-reviews" +type Feature = "reviews" | "ai-reviews" | "altTags" const features: Record> = { reviews: { @@ -11,6 +11,10 @@ const features: Record> = { message: "No keys provided for ai reviews summary feautre, to opt-in set envrioment variables: OpenAI API, JUDGE_API_TOKEN ", predicate: !!env.OPENAI_API_KEY, }, + altTags: { + message: "No keys provided for alt tags feature, to opt-in set environment variables: REPLICATE_API_KEY", + predicate: !!env.REPLICATE_API_KEY, + }, } const optInNotification = ({ message, source }: { message: string; source?: string }) => { diff --git a/starters/shopify-algolia/yarn.lock b/starters/shopify-algolia/yarn.lock index 6c51ebfe..7bba8e49 100644 --- a/starters/shopify-algolia/yarn.lock +++ b/starters/shopify-algolia/yarn.lock @@ -1329,6 +1329,126 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== +"@esbuild/aix-ppc64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz#b57697945b50e99007b4c2521507dc613d4a648c" + integrity sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw== + +"@esbuild/android-arm64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz#1add7e0af67acefd556e407f8497e81fddad79c0" + integrity sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w== + +"@esbuild/android-arm@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.24.0.tgz#ab7263045fa8e090833a8e3c393b60d59a789810" + integrity sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew== + +"@esbuild/android-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.24.0.tgz#e8f8b196cfdfdd5aeaebbdb0110983460440e705" + integrity sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ== + +"@esbuild/darwin-arm64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz#2d0d9414f2acbffd2d86e98253914fca603a53dd" + integrity sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw== + +"@esbuild/darwin-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz#33087aab31a1eb64c89daf3d2cf8ce1775656107" + integrity sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA== + +"@esbuild/freebsd-arm64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz#bb76e5ea9e97fa3c753472f19421075d3a33e8a7" + integrity sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA== + +"@esbuild/freebsd-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz#e0e2ce9249fdf6ee29e5dc3d420c7007fa579b93" + integrity sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ== + +"@esbuild/linux-arm64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz#d1b2aa58085f73ecf45533c07c82d81235388e75" + integrity sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g== + +"@esbuild/linux-arm@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz#8e4915df8ea3e12b690a057e77a47b1d5935ef6d" + integrity sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw== + +"@esbuild/linux-ia32@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz#8200b1110666c39ab316572324b7af63d82013fb" + integrity sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA== + +"@esbuild/linux-loong64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz#6ff0c99cf647504df321d0640f0d32e557da745c" + integrity sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g== + +"@esbuild/linux-mips64el@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz#3f720ccd4d59bfeb4c2ce276a46b77ad380fa1f3" + integrity sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA== + +"@esbuild/linux-ppc64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz#9d6b188b15c25afd2e213474bf5f31e42e3aa09e" + integrity sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ== + +"@esbuild/linux-riscv64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz#f989fdc9752dfda286c9cd87c46248e4dfecbc25" + integrity sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw== + +"@esbuild/linux-s390x@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz#29ebf87e4132ea659c1489fce63cd8509d1c7319" + integrity sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g== + +"@esbuild/linux-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz#4af48c5c0479569b1f359ffbce22d15f261c0cef" + integrity sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA== + +"@esbuild/netbsd-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz#1ae73d23cc044a0ebd4f198334416fb26c31366c" + integrity sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg== + +"@esbuild/openbsd-arm64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz#5d904a4f5158c89859fd902c427f96d6a9e632e2" + integrity sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg== + +"@esbuild/openbsd-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz#4c8aa88c49187c601bae2971e71c6dc5e0ad1cdf" + integrity sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q== + +"@esbuild/sunos-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz#8ddc35a0ea38575fa44eda30a5ee01ae2fa54dd4" + integrity sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA== + +"@esbuild/win32-arm64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz#6e79c8543f282c4539db684a207ae0e174a9007b" + integrity sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA== + +"@esbuild/win32-ia32@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz#057af345da256b7192d18b676a02e95d0fa39103" + integrity sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw== + +"@esbuild/win32-x64@0.24.0": + version "0.24.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz#168ab1c7e1c318b922637fad8f339d48b01e1244" + integrity sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA== + "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.1" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz#d1145bf2c20132d6400495d6df4bf59362fd9d56" @@ -3025,6 +3145,96 @@ resolved "https://registry.yarnpkg.com/@repeaterjs/repeater/-/repeater-3.0.6.tgz#be23df0143ceec3c69f8b6c2517971a5578fdaa2" integrity sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA== +"@rollup/rollup-android-arm-eabi@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.4.tgz#c460b54c50d42f27f8254c435a4f3b3e01910bc8" + integrity sha512-jfUJrFct/hTA0XDM5p/htWKoNNTbDLY0KRwEt6pyOA6k2fmk0WVwl65PdUdJZgzGEHWx+49LilkcSaumQRyNQw== + +"@rollup/rollup-android-arm64@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.4.tgz#96e01f3a04675d8d5973ab8d3fd6bc3be21fa5e1" + integrity sha512-j4nrEO6nHU1nZUuCfRKoCcvh7PIywQPUCBa2UsootTHvTHIoIu2BzueInGJhhvQO/2FTRdNYpf63xsgEqH9IhA== + +"@rollup/rollup-darwin-arm64@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.4.tgz#9b2ec23b17b47cbb2f771b81f86ede3ac6730bce" + integrity sha512-GmU/QgGtBTeraKyldC7cDVVvAJEOr3dFLKneez/n7BvX57UdhOqDsVwzU7UOnYA7AAOt+Xb26lk79PldDHgMIQ== + +"@rollup/rollup-darwin-x64@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.4.tgz#f30e4ee6929e048190cf10e0daa8e8ae035b6e46" + integrity sha512-N6oDBiZCBKlwYcsEPXGDE4g9RoxZLK6vT98M8111cW7VsVJFpNEqvJeIPfsCzbf0XEakPslh72X0gnlMi4Ddgg== + +"@rollup/rollup-freebsd-arm64@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.24.4.tgz#c54b2373ec5bcf71f08c4519c7ae80a0b6c8e03b" + integrity sha512-py5oNShCCjCyjWXCZNrRGRpjWsF0ic8f4ieBNra5buQz0O/U6mMXCpC1LvrHuhJsNPgRt36tSYMidGzZiJF6mw== + +"@rollup/rollup-freebsd-x64@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.24.4.tgz#3bc53aa29d5a34c28ba8e00def76aa612368458e" + integrity sha512-L7VVVW9FCnTTp4i7KrmHeDsDvjB4++KOBENYtNYAiYl96jeBThFfhP6HVxL74v4SiZEVDH/1ILscR5U9S4ms4g== + +"@rollup/rollup-linux-arm-gnueabihf@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.4.tgz#c85aedd1710c9e267ee86b6d1ce355ecf7d9e8d9" + integrity sha512-10ICosOwYChROdQoQo589N5idQIisxjaFE/PAnX2i0Zr84mY0k9zul1ArH0rnJ/fpgiqfu13TFZR5A5YJLOYZA== + +"@rollup/rollup-linux-arm-musleabihf@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.4.tgz#e77313408bf13995aecde281aec0cceb08747e42" + integrity sha512-ySAfWs69LYC7QhRDZNKqNhz2UKN8LDfbKSMAEtoEI0jitwfAG2iZwVqGACJT+kfYvvz3/JgsLlcBP+WWoKCLcw== + +"@rollup/rollup-linux-arm64-gnu@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.4.tgz#633f632397b3662108cfaa1abca2a80b85f51102" + integrity sha512-uHYJ0HNOI6pGEeZ/5mgm5arNVTI0nLlmrbdph+pGXpC9tFHFDQmDMOEqkmUObRfosJqpU8RliYoGz06qSdtcjg== + +"@rollup/rollup-linux-arm64-musl@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.4.tgz#63edd72b29c4cced93e16113a68e1be9fef88907" + integrity sha512-38yiWLemQf7aLHDgTg85fh3hW9stJ0Muk7+s6tIkSUOMmi4Xbv5pH/5Bofnsb6spIwD5FJiR+jg71f0CH5OzoA== + +"@rollup/rollup-linux-powerpc64le-gnu@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.4.tgz#a9418a4173df80848c0d47df0426a0bf183c4e75" + integrity sha512-q73XUPnkwt9ZNF2xRS4fvneSuaHw2BXuV5rI4cw0fWYVIWIBeDZX7c7FWhFQPNTnE24172K30I+dViWRVD9TwA== + +"@rollup/rollup-linux-riscv64-gnu@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.4.tgz#bc9c195db036a27e5e3339b02f51526b4ce1e988" + integrity sha512-Aie/TbmQi6UXokJqDZdmTJuZBCU3QBDA8oTKRGtd4ABi/nHgXICulfg1KI6n9/koDsiDbvHAiQO3YAUNa/7BCw== + +"@rollup/rollup-linux-s390x-gnu@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.4.tgz#1651fdf8144ae89326c01da5d52c60be63e71a82" + integrity sha512-P8MPErVO/y8ohWSP9JY7lLQ8+YMHfTI4bAdtCi3pC2hTeqFJco2jYspzOzTUB8hwUWIIu1xwOrJE11nP+0JFAQ== + +"@rollup/rollup-linux-x64-gnu@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.4.tgz#e473de5e4acb95fcf930a35cbb7d3e8080e57a6f" + integrity sha512-K03TljaaoPK5FOyNMZAAEmhlyO49LaE4qCsr0lYHUKyb6QacTNF9pnfPpXnFlFD3TXuFbFbz7tJ51FujUXkXYA== + +"@rollup/rollup-linux-x64-musl@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.4.tgz#0af12dd2578c29af4037f0c834b4321429dd5b01" + integrity sha512-VJYl4xSl/wqG2D5xTYncVWW+26ICV4wubwN9Gs5NrqhJtayikwCXzPL8GDsLnaLU3WwhQ8W02IinYSFJfyo34Q== + +"@rollup/rollup-win32-arm64-msvc@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.4.tgz#e48e78cdd45313b977c1390f4bfde7ab79be8871" + integrity sha512-ku2GvtPwQfCqoPFIJCqZ8o7bJcj+Y54cZSr43hHca6jLwAiCbZdBUOrqE6y29QFajNAzzpIOwsckaTFmN6/8TA== + +"@rollup/rollup-win32-ia32-msvc@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.4.tgz#a3fc8536d243fe161c796acb93eba43c250f311c" + integrity sha512-V3nCe+eTt/W6UYNr/wGvO1fLpHUrnlirlypZfKCT1fG6hWfqhPgQV/K/mRBXBpxc0eKLIF18pIOFVPh0mqHjlg== + +"@rollup/rollup-win32-x64-msvc@4.24.4": + version "4.24.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.4.tgz#e2a9d1fd56524103a6cc8a54404d9d3ebc73c454" + integrity sha512-LTw1Dfd0mBIEqUVCxbvTE/LLo+9ZxVC9k99v1v4ahg9Aak6FpqOfNu5kRkeTAn0wphoC4JU7No1/rL+bBCEwhg== + "@rtsao/scc@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" @@ -3359,6 +3569,11 @@ resolved "https://registry.yarnpkg.com/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz#dcef10a69d357fe9d43ac4ff2eca6b85dbf466af" integrity sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg== +"@types/estree@1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" + integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== + "@types/graceful-fs@^4.1.3": version "4.1.9" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" @@ -4558,6 +4773,13 @@ builtin-modules@^3.3.0: resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.3.0.tgz#cae62812b89801e9656336e46223e030386be7b6" integrity sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw== +bundle-require@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/bundle-require/-/bundle-require-5.0.0.tgz#071521bdea6534495cf23e92a83f889f91729e93" + integrity sha512-GuziW3fSSmopcx4KRymQEJVbZUfqlCqcq7dvs6TYwKRZiegK/2buMxQTPs6MGlNv50wms1699qYO54R8XfRX4w== + dependencies: + load-tsconfig "^0.2.3" + busboy@1.6.0, busboy@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" @@ -4565,6 +4787,11 @@ busboy@1.6.0, busboy@^1.6.0: dependencies: streamsearch "^1.1.0" +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" @@ -4698,6 +4925,13 @@ chokidar@^3.5.3: optionalDependencies: fsevents "~2.3.2" +chokidar@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.1.tgz#4a6dff66798fb0f72a94f616abbd7e1a19f31d41" + integrity sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA== + dependencies: + readdirp "^4.0.1" + chownr@^1.1.1: version "1.1.4" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" @@ -4865,6 +5099,11 @@ confusing-browser-globals@^1.0.11: resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81" integrity sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA== +consola@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/consola/-/consola-3.2.3.tgz#0741857aa88cfa0d6fd53f1cff0375136e98502f" + integrity sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ== + constant-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/constant-case/-/constant-case-3.0.4.tgz#3b84a9aeaf4cf31ec45e6bf5de91bdfb0589faf1" @@ -5041,7 +5280,7 @@ debounce@^1.2.0, debounce@^1.2.1: resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5: +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.7: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== @@ -5267,7 +5506,7 @@ dotenv@16.0.3: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07" integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ== -dotenv@^16.0.0: +dotenv@^16.0.0, dotenv@^16.4.5: version "16.4.5" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== @@ -5512,6 +5751,36 @@ es-vary@^0.0.8: resolved "https://registry.yarnpkg.com/es-vary/-/es-vary-0.0.8.tgz#4dc62235dda14e51e16b6690e564a9e5d40e3df0" integrity sha512-fiERjQiCHrXUAToNRT/sh7MtXnfei9n7cF9oVQRUEp9L5BGXsTKSPaXq8L+4v0c/ezfvuTWd/f0JSl5IBRUvSg== +esbuild@^0.24.0: + version "0.24.0" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.24.0.tgz#f2d470596885fcb2e91c21eb3da3b3c89c0b55e7" + integrity sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ== + optionalDependencies: + "@esbuild/aix-ppc64" "0.24.0" + "@esbuild/android-arm" "0.24.0" + "@esbuild/android-arm64" "0.24.0" + "@esbuild/android-x64" "0.24.0" + "@esbuild/darwin-arm64" "0.24.0" + "@esbuild/darwin-x64" "0.24.0" + "@esbuild/freebsd-arm64" "0.24.0" + "@esbuild/freebsd-x64" "0.24.0" + "@esbuild/linux-arm" "0.24.0" + "@esbuild/linux-arm64" "0.24.0" + "@esbuild/linux-ia32" "0.24.0" + "@esbuild/linux-loong64" "0.24.0" + "@esbuild/linux-mips64el" "0.24.0" + "@esbuild/linux-ppc64" "0.24.0" + "@esbuild/linux-riscv64" "0.24.0" + "@esbuild/linux-s390x" "0.24.0" + "@esbuild/linux-x64" "0.24.0" + "@esbuild/netbsd-x64" "0.24.0" + "@esbuild/openbsd-arm64" "0.24.0" + "@esbuild/openbsd-x64" "0.24.0" + "@esbuild/sunos-x64" "0.24.0" + "@esbuild/win32-arm64" "0.24.0" + "@esbuild/win32-ia32" "0.24.0" + "@esbuild/win32-x64" "0.24.0" + escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" @@ -6085,6 +6354,11 @@ fbjs@^3.0.0: setimmediate "^1.0.5" ua-parser-js "^1.0.35" +fdir@^6.4.2: + version "6.4.2" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.2.tgz#ddaa7ce1831b161bc3657bb99cb36e1622702689" + integrity sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ== + figures@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -6339,6 +6613,18 @@ glob@^10.3.10: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" +glob@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-11.0.0.tgz#6031df0d7b65eaa1ccb9b29b5ced16cea658e77e" + integrity sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g== + dependencies: + foreground-child "^3.1.0" + jackspeak "^4.0.1" + minimatch "^10.0.0" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^2.0.0" + glob@^7.1.1, glob@^7.1.3, glob@^7.1.4: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" @@ -7084,6 +7370,13 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jackspeak@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.0.2.tgz#11f9468a3730c6ff6f56823a820d7e3be9bef015" + integrity sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw== + dependencies: + "@isaacs/cliui" "^8.0.2" + jake@^10.8.5: version "10.9.2" resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.2.tgz#6ae487e6a69afec3a5e167628996b59f35ae2b7f" @@ -7491,6 +7784,11 @@ jose@^5.0.0: resolved "https://registry.yarnpkg.com/jose/-/jose-5.9.6.tgz#77f1f901d88ebdc405e57cce08d2a91f47521883" integrity sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ== +joycon@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" + integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -7690,7 +7988,7 @@ lilconfig@^2.1.0: resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== -lilconfig@^3.0.0: +lilconfig@^3.0.0, lilconfig@^3.1.1: version "3.1.2" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.2.tgz#e4a7c3cb549e3a606c8dcc32e5ae1005e62c05cb" integrity sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow== @@ -7714,6 +8012,11 @@ listr2@^4.0.5: through "^2.3.8" wrap-ansi "^7.0.0" +load-tsconfig@^0.2.3: + version "0.2.5" + resolved "https://registry.yarnpkg.com/load-tsconfig/-/load-tsconfig-0.2.5.tgz#453b8cd8961bfb912dea77eb6c168fe8cca3d3a1" + integrity sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg== + locate-path@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" @@ -7743,6 +8046,11 @@ lodash.indexof@^4.0.5: resolved "https://registry.yarnpkg.com/lodash.indexof/-/lodash.indexof-4.0.5.tgz#53714adc2cddd6ed87638f893aa9b6c24e31ef3c" integrity sha512-t9wLWMQsawdVmf6/IcAgVGqAJkNzYVcn4BHYZKTPW//l7N5Oq7Bq138BaVk19agcsPZePcidSgTTw4NqS1nUAw== +lodash.isequal@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== + lodash.memoize@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" @@ -7753,6 +8061,11 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash.omit@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.omit/-/lodash.omit-4.5.0.tgz#6eb19ae5a1ee1dd9df0b969e66ce0b7fa30b5e60" + integrity sha512-XeqSp49hNGmlkj2EJlfrQFIzQ6lXdNro9sddtQzcJY8QaoC2GO0DT7xaIokHeyM+mIT0mPMlPvkYzg2xCuHdZg== + lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" @@ -7812,6 +8125,11 @@ lru-cache@^10.2.0: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== +lru-cache@^11.0.0: + version "11.0.2" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.0.2.tgz#fbd8e7cf8211f5e7e5d91905c415a3f55755ca39" + integrity sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -7910,6 +8228,13 @@ minimatch@9.0.3: dependencies: brace-expansion "^2.0.1" +minimatch@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.1.tgz#ce0521856b453c86e25f2c4c0d03e6ff7ddc440b" + integrity sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ== + dependencies: + brace-expansion "^2.0.1" + minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -8436,12 +8761,20 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" +path-scurry@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.0.tgz#9f052289f23ad8bf9397a2a0425e7b8615c58580" + integrity sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -picocolors@^1.0.0, picocolors@^1.0.1, picocolors@^1.1.0: +picocolors@^1.0.0, picocolors@^1.0.1, picocolors@^1.1.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== @@ -8451,6 +8784,11 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +picomatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" + integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== + pify@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -8516,6 +8854,13 @@ postcss-load-config@^4.0.1: lilconfig "^3.0.0" yaml "^2.3.4" +postcss-load-config@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz#6fd7dcd8ae89badcf1b2d644489cbabf83aa8096" + integrity sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g== + dependencies: + lilconfig "^3.1.1" + postcss-nested@^6.0.1: version "6.2.0" resolved "https://registry.yarnpkg.com/postcss-nested/-/postcss-nested-6.2.0.tgz#4c2d22ab5f20b9cb61e2c5c5915950784d068131" @@ -8821,6 +9166,11 @@ readable-stream@^3.1.1, readable-stream@^3.4.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readdirp@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.0.2.tgz#388fccb8b75665da3abffe2d8f8ed59fe74c230a" + integrity sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA== + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -9065,6 +9415,41 @@ rimraf@^3.0.2: dependencies: glob "^7.1.3" +rimraf@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-6.0.1.tgz#ffb8ad8844dd60332ab15f52bc104bc3ed71ea4e" + integrity sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A== + dependencies: + glob "^11.0.0" + package-json-from-dist "^1.0.0" + +rollup@^4.24.0: + version "4.24.4" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.24.4.tgz#fdc76918de02213c95447c9ffff5e35dddb1d058" + integrity sha512-vGorVWIsWfX3xbcyAS+I047kFKapHYivmkaT63Smj77XwvLSJos6M1xGqZnBPFQFBRZDOcG1QnYEIxAvTr/HjA== + dependencies: + "@types/estree" "1.0.6" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.24.4" + "@rollup/rollup-android-arm64" "4.24.4" + "@rollup/rollup-darwin-arm64" "4.24.4" + "@rollup/rollup-darwin-x64" "4.24.4" + "@rollup/rollup-freebsd-arm64" "4.24.4" + "@rollup/rollup-freebsd-x64" "4.24.4" + "@rollup/rollup-linux-arm-gnueabihf" "4.24.4" + "@rollup/rollup-linux-arm-musleabihf" "4.24.4" + "@rollup/rollup-linux-arm64-gnu" "4.24.4" + "@rollup/rollup-linux-arm64-musl" "4.24.4" + "@rollup/rollup-linux-powerpc64le-gnu" "4.24.4" + "@rollup/rollup-linux-riscv64-gnu" "4.24.4" + "@rollup/rollup-linux-s390x-gnu" "4.24.4" + "@rollup/rollup-linux-x64-gnu" "4.24.4" + "@rollup/rollup-linux-x64-musl" "4.24.4" + "@rollup/rollup-win32-arm64-msvc" "4.24.4" + "@rollup/rollup-win32-ia32-msvc" "4.24.4" + "@rollup/rollup-win32-x64-msvc" "4.24.4" + fsevents "~2.3.2" + run-async@^2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" @@ -9377,6 +9762,13 @@ source-map-support@0.5.13: buffer-from "^1.0.0" source-map "^0.6.0" +source-map@0.8.0-beta.0: + version "0.8.0-beta.0" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.8.0-beta.0.tgz#d4c1bb42c3f7ee925f005927ba10709e0d1d1f11" + integrity sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA== + dependencies: + whatwg-url "^7.0.0" + source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" @@ -9632,7 +10024,7 @@ styled-jsx@5.1.1: dependencies: client-only "0.0.1" -sucrase@^3.32.0: +sucrase@^3.32.0, sucrase@^3.35.0: version "3.35.0" resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.0.tgz#57f17a3d7e19b36d8995f06679d121be914ae263" integrity sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA== @@ -9829,6 +10221,19 @@ through@^2.3.6, through@^2.3.8: resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== +tinyexec@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.1.tgz#0ab0daf93b43e2c211212396bdb836b468c97c98" + integrity sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ== + +tinyglobby@^0.2.9: + version "0.2.10" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.10.tgz#e712cf2dc9b95a1f5c5bbd159720e15833977a0f" + integrity sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew== + dependencies: + fdir "^6.4.2" + picomatch "^4.0.2" + title-case@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/title-case/-/title-case-3.0.3.tgz#bc689b46f02e411f1d1e1d081f7c3deca0489982" @@ -9870,6 +10275,13 @@ tough-cookie@^4.1.2: universalify "^0.2.0" url-parse "^1.5.3" +tr46@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" + integrity sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA== + dependencies: + punycode "^2.1.0" + tr46@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/tr46/-/tr46-3.0.0.tgz#555c4e297a950617e8eeddef633c87d4d9d6cbf9" @@ -9882,6 +10294,11 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== +tree-kill@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== + ts-api-utils@^1.0.1, ts-api-utils@^1.3.0: version "1.4.0" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.0.tgz#709c6f2076e511a81557f3d07a0cbd566ae8195c" @@ -9961,6 +10378,28 @@ tslib@~2.6.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== +tsup@^8.3.5: + version "8.3.5" + resolved "https://registry.yarnpkg.com/tsup/-/tsup-8.3.5.tgz#d55344e4756e924bf6f442e54e7d324b4471eee0" + integrity sha512-Tunf6r6m6tnZsG9GYWndg0z8dEV7fD733VBFzFJ5Vcm1FtlXB8xBD/rtrBi2a3YKEV7hHtxiZtW5EAVADoe1pA== + dependencies: + bundle-require "^5.0.0" + cac "^6.7.14" + chokidar "^4.0.1" + consola "^3.2.3" + debug "^4.3.7" + esbuild "^0.24.0" + joycon "^3.1.1" + picocolors "^1.1.1" + postcss-load-config "^6.0.1" + resolve-from "^5.0.0" + rollup "^4.24.0" + source-map "0.8.0-beta.0" + sucrase "^3.35.0" + tinyexec "^0.3.1" + tinyglobby "^0.2.9" + tree-kill "^1.2.2" + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -10267,6 +10706,11 @@ webidl-conversions@^3.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== +webidl-conversions@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" + integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== + webidl-conversions@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" @@ -10319,6 +10763,15 @@ whatwg-url@^5.0.0: tr46 "~0.0.3" webidl-conversions "^3.0.0" +whatwg-url@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" + integrity sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg== + dependencies: + lodash.sortby "^4.7.0" + tr46 "^1.0.1" + webidl-conversions "^4.0.2" + which-boxed-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6" diff --git a/starters/shopify-meilisearch/app/api/feed/sync/route.ts b/starters/shopify-meilisearch/app/api/feed/sync/route.ts index 038de440..5df727de 100644 --- a/starters/shopify-meilisearch/app/api/feed/sync/route.ts +++ b/starters/shopify-meilisearch/app/api/feed/sync/route.ts @@ -1,9 +1,13 @@ import type { PlatformProduct } from "lib/shopify/types" import { env } from "env.mjs" import { compareHmac } from "utils/compare-hmac" -import { enrichProduct } from "utils/enrich-product" +import { ProductEnrichmentBuilder } from "utils/enrich-product" import { deleteCategories, deleteProducts, updateCategories, updateProducts } from "lib/meilisearch" import { getCollection, getHierarchicalCollections, getProduct } from "lib/shopify" +import { makeShopifyId } from "lib/shopify/utils" +import { HIERARCHICAL_SEPARATOR } from "constants/index" +import { isOptIn } from "utils/opt-in" +import { getAllProductReviews } from "lib/reviews" type SupportedTopic = "products/update" | "products/delete" | "products/create" | "collections/update" | "collections/delete" | "collections/create" @@ -56,7 +60,6 @@ async function handleCollectionTopics(topic: SupportedTopic, { id }: RecordFiltering, searching, and adding to cart is disabled.

diff --git a/starters/shopify-meilisearch/lib/shopify/client.ts b/starters/shopify-meilisearch/lib/shopify/client.ts index e549e135..f1da0488 100644 --- a/starters/shopify-meilisearch/lib/shopify/client.ts +++ b/starters/shopify-meilisearch/lib/shopify/client.ts @@ -57,6 +57,7 @@ import { } from "./types" import { env } from "env.mjs" +import { cleanShopifyId, makeShopifyId } from "./utils" interface CreateShopifyClientProps { storeDomain: string @@ -129,7 +130,7 @@ async function getHierarchicalCollections(client: StorefrontApiClient, handle: s } async function getProduct(client: StorefrontApiClient, id: string): Promise { - const response = await client.request(getProductQuery, { variables: { id } }) + const response = await client.request(getProductQuery, { variables: { id: makeShopifyId(id, "Product") } }) const product = response.data?.product return normalizeProduct(product) @@ -178,7 +179,7 @@ async function getAllPages(client: StorefrontApiClient): Promise { - const status = await client.request(getProductStatusQuery, { variables: { id } }) + const status = await client.request(getProductStatusQuery, { variables: { id: makeShopifyId(id, "Product") } }) return status.data?.product } @@ -226,7 +227,7 @@ async function getCollection(client: StorefrontApiClient, handle: string): Promi } async function getCollectionById(client: StorefrontApiClient, id: string): Promise { - const collection = await client.request(getCollectionByIdQuery, { variables: { id } }) + const collection = await client.request(getCollectionByIdQuery, { variables: { id: makeShopifyId(id, "Collection") } }) return normalizeCollection(collection.data?.collection) } @@ -257,7 +258,7 @@ async function updateUser(client: StorefrontApiClient, customerAccessToken: stri async function getAdminProduct(client: AdminApiClient, id: string) { const response = await client.request(getAdminProductQuery, { - variables: { id: id.startsWith("gid://shopify/Product/") ? id : `gid://shopify/Product/${id}` }, + variables: { id: makeShopifyId(id, "Product") }, }) if (!response.data?.product) return null @@ -285,7 +286,7 @@ async function getAllProducts(client: StorefrontApiClient, limit: number = 250): cursor = hasNextPage ? response.data?.products?.pageInfo?.endCursor : null } - return products + return products.map((product) => ({ ...product, id: cleanShopifyId(product.id, "Product") })) } async function getAllCollections(client: StorefrontApiClient, limit?: number) { @@ -302,7 +303,7 @@ async function getAllCollections(client: StorefrontApiClient, limit?: number) { cursor = hasNextPage ? response?.data?.collections?.pageInfo?.endCursor : null } - return collections + return collections.map((collection) => ({ ...collection, id: cleanShopifyId(collection.id, "Collection") })) } export const storefrontClient = createShopifyClient({ diff --git a/starters/shopify-meilisearch/lib/shopify/normalize.ts b/starters/shopify-meilisearch/lib/shopify/normalize.ts index 7142b2aa..b2f58335 100644 --- a/starters/shopify-meilisearch/lib/shopify/normalize.ts +++ b/starters/shopify-meilisearch/lib/shopify/normalize.ts @@ -1,12 +1,13 @@ import { SingleCartQuery, SingleCollectionQuery, SingleProductQuery } from "./types/storefront.generated" import type { PlatformCart, PlatformCartItem, PlatformCollection, PlatformProduct } from "./types" +import { cleanShopifyId } from "./utils" export function normalizeProduct(product: SingleProductQuery["product"]): PlatformProduct | null { if (!product) return null const { id, handle, title, description, vendor, descriptionHtml, options, priceRange, variants, featuredImage, images, tags, updatedAt, createdAt, collections, seo } = product return { - id, + id: cleanShopifyId(id, "Product"), handle, title, description, @@ -46,5 +47,5 @@ export function normalizeCollection(collection: SingleCollectionQuery["collectio if (!collection) return null const { id, handle, title, descriptionHtml, seo, image, updatedAt, description } = collection - return { id, handle, title, descriptionHtml, seo, image, updatedAt, description } + return { id: cleanShopifyId(id, "Collection"), handle, title, descriptionHtml, seo, image, updatedAt, description } } diff --git a/starters/shopify-meilisearch/lib/shopify/types/storefront.generated.d.ts b/starters/shopify-meilisearch/lib/shopify/types/storefront.generated.d.ts index 8c2c6d95..94739202 100644 --- a/starters/shopify-meilisearch/lib/shopify/types/storefront.generated.d.ts +++ b/starters/shopify-meilisearch/lib/shopify/types/storefront.generated.d.ts @@ -544,7 +544,8 @@ export type SingleCollectionQuery = { } export type CollectionsQueryVariables = StorefrontTypes.Exact<{ - limit?: StorefrontTypes.InputMaybe + first?: StorefrontTypes.InputMaybe + after?: StorefrontTypes.InputMaybe }> export type CollectionsQuery = { @@ -555,6 +556,7 @@ export type CollectionsQuery = { seo: Pick } }> + pageInfo: Pick } } @@ -716,7 +718,7 @@ interface GeneratedQueryTypes { return: SingleCollectionQuery variables: SingleCollectionQueryVariables } - "#graphql\n query Collections($limit: Int = 250) {\n collections(first: $limit, sortKey: TITLE) {\n edges {\n node {\n ...singleCollection\n }\n }\n }\n }\n #graphql\n fragment singleCollection on Collection {\n handle\n image {\n ...singleImage\n }\n title\n descriptionHtml\n id\n description\n seo {\n ...seo\n }\n updatedAt\n }\n #graphql\n fragment seo on SEO {\n description\n title\n }\n\n #graphql\n fragment singleImage on Image {\n url\n altText\n width\n height\n }\n\n\n": { + "#graphql\n query Collections($first: Int = 250, $after: String) {\n collections(first: $first, after: $after, sortKey: TITLE) {\n edges {\n node {\n ...singleCollection\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n #graphql\n fragment singleCollection on Collection {\n handle\n image {\n ...singleImage\n }\n title\n descriptionHtml\n id\n description\n seo {\n ...seo\n }\n updatedAt\n }\n #graphql\n fragment seo on SEO {\n description\n title\n }\n\n #graphql\n fragment singleImage on Image {\n url\n altText\n width\n height\n }\n\n\n": { return: CollectionsQuery variables: CollectionsQueryVariables } diff --git a/starters/shopify-meilisearch/lib/shopify/utils.ts b/starters/shopify-meilisearch/lib/shopify/utils.ts new file mode 100644 index 00000000..3349ec83 --- /dev/null +++ b/starters/shopify-meilisearch/lib/shopify/utils.ts @@ -0,0 +1,7 @@ +export function makeShopifyId(id: string, type: "Product" | "Collection") { + return id.startsWith("gid://shopify/") ? id : `gid://shopify/${type}/${id}` +} + +export function cleanShopifyId(id: string, type: "Product" | "Collection") { + return id.replace(`gid://shopify/${type}/`, "") +} diff --git a/starters/shopify-meilisearch/package.json b/starters/shopify-meilisearch/package.json index e0e29086..83691b02 100644 --- a/starters/shopify-meilisearch/package.json +++ b/starters/shopify-meilisearch/package.json @@ -20,7 +20,7 @@ "codegen": "graphql-codegen && graphql-codegen -p admin", "coupling-graph": "npx madge --extensions js,jsx,ts,tsx,css,md,mdx ./ --exclude '.next|tailwind.config.js|reset.d.ts|prettier.config.js|postcss.config.js|playwright.config.ts|next.config.js|next-env.d.ts|instrumentation.ts|e2e/|README.md|eslintrc.js' --image graph.svg", "sync": "yarn build:scripts && node scripts/dist/sync.mjs", - "build:scripts": "tsup scripts/ -d scripts/dist" + "build:scripts": "rimraf scripts/dist && tsup scripts/ -d scripts/dist" }, "dependencies": { "@ai-sdk/openai": "^0.0.16", @@ -109,6 +109,7 @@ "patch-package": "^8.0.0", "prettier": "3.2.5", "prettier-plugin-tailwindcss": "^0.5.12", + "rimraf": "^6.0.1", "server-only": "^0.0.1", "tailwind-merge": "^2.2.2", "tailwindcss": "^3.4.1", diff --git a/starters/shopify-meilisearch/scripts/sync.ts b/starters/shopify-meilisearch/scripts/sync.ts index 4daf9eea..b864fab4 100644 --- a/starters/shopify-meilisearch/scripts/sync.ts +++ b/starters/shopify-meilisearch/scripts/sync.ts @@ -2,9 +2,13 @@ import isEqual from "lodash.isequal" import { HIERARCHICAL_SEPARATOR } from "../constants/index" import { storefrontClient } from "../lib/shopify/client" -import type { PlatformMenu, PlatformProduct } from "../lib/shopify/types" +import type { PlatformProduct } from "../lib/shopify/types" import { searchClient } from "../lib/meilisearch/client" import { env } from "../env.mjs" +import { isOptIn } from "utils/opt-in" +import { reviewsClient } from "lib/reviews/client" +import { CommerceProduct } from "types" +import { ProductEnrichmentBuilder } from "utils/enrich-product" async function sync() { console.log("🚀 Starting sync process...") @@ -28,7 +32,11 @@ async function sync() { console.log(`✓ Found ${hierarchicalCategories.items.length} hierarchical categories`) console.log("🔄 Enriching products with hierarchical data...") - const enrichedProducts = await enrichProducts(allProducts, hierarchicalCategories.items, HIERARCHICAL_SEPARATOR) + const reviews = isOptIn("reviews") ? await reviewsClient.getAllProductReviews() : [] + + const enrichedProducts = allProducts.map((product) => + new ProductEnrichmentBuilder(product).withHierarchicalCategories(hierarchicalCategories.items, HIERARCHICAL_SEPARATOR).withReviews(reviews).build() + ) console.log("📥 Fetching current Meilisearch indices...") const { results: allIndexProducts } = await searchClient.getDocuments({ @@ -37,43 +45,29 @@ async function sync() { limit: 10000, }, }) + + console.log(`✓ Found ${allIndexProducts.length} products in Meilisearch`) + const { results: allIndexCategories } = await searchClient.getDocuments({ indexName: env.MEILISEARCH_CATEGORIES_INDEX, options: { limit: 10000, }, }) + console.log(`✓ Found ${allIndexCategories.length} categories in Meilisearch`) console.log("🔍 Calculating differences...") - const deltaProducts = enrichedProducts.filter((product) => { - return !isEqual(allIndexProducts.find((p) => p.id === product.id)) - }) + const deltaProducts = calculateProductDelta(enrichedProducts, allIndexProducts) + const deltaCategories = calculateCategoryDelta(allCategories, allIndexCategories) - const deltaCategories = allCategories.filter((category) => !isEqual(allIndexCategories.find((c) => c.id === category.id))) + await updateMeilisearchDocuments(env.MEILISEARCH_PRODUCTS_INDEX, deltaProducts, "products") + await updateMeilisearchDocuments(env.MEILISEARCH_CATEGORIES_INDEX, deltaCategories, "categories") if (deltaProducts.length === 0 && deltaCategories.length === 0) { console.log("✨ Nothing to sync, looks like you're all set!") return } - if (deltaProducts.length > 0) { - console.log(`📤 Updating ${deltaProducts.length} products in Meilisearch...`) - await searchClient.updateDocuments({ - indexName: env.MEILISEARCH_PRODUCTS_INDEX, - documents: deltaProducts, - }) - console.log("✓ Products updated successfully") - } - - if (deltaCategories.length > 0) { - console.log(`📤 Updating ${deltaCategories.length} categories in Meilisearch...`) - await searchClient.updateDocuments({ - indexName: env.MEILISEARCH_CATEGORIES_INDEX, - documents: deltaCategories, - }) - console.log("✓ Categories updated successfully") - } - console.log("🎉 Sync completed successfully!") } catch (error) { console.error("❌ Error during sync:", error instanceof Error ? error.message : error) @@ -81,61 +75,39 @@ async function sync() { } } -async function enrichProducts(products: PlatformProduct[], categories: PlatformMenu["items"], separator: string) { - if (!categories.length) { - return products - } - const categoryMap = await buildCategoryMap(categories) +async function updateMeilisearchDocuments>(indexName: string, documents: T[], entityName: string) { + if (documents.length === 0) return - return products.map((product) => ({ - ...product, - hierarchicalCategories: generateHierarchicalCategories(product.collections, categoryMap, separator), - })) + console.log(`📤 Updating ${documents.length} ${entityName} in Meilisearch...`) + await searchClient.updateDocuments({ + indexName, + options: { primaryKey: "id" }, + documents, + }) } -async function buildCategoryMap(categories: PlatformMenu["items"]) { - const categoryMap = new Map() +function calculateProductDelta(enrichedProducts: CommerceProduct[], allIndexProducts: any[]) { + const allIndexProductsMap = new Map(allIndexProducts.map((product) => [product.id, product])) - function traverse(items: PlatformMenu["items"], path: string[]) { - for (const item of items) { - const newPath = [...path, item.resource?.handle || ""] - categoryMap.set(item.resource?.handle || "", newPath) - if (item.items && item.items.length > 0) { - traverse(item.items, newPath) - } + return enrichedProducts.reduce>((acc, product) => { + const existingProduct = allIndexProductsMap.get(product.id) + if (!existingProduct || !isEqual(product, existingProduct)) { + acc.push(product) } - } - - traverse(categories, []) - - return categoryMap + return acc + }, []) } -function generateHierarchicalCategories(collections: PlatformProduct["collections"], categoryMap: Map, separator: string = " > ") { - const hierarchicalCategories: { lvl0: string[]; lvl1: string[]; lvl2: string[] } = { lvl0: [], lvl1: [], lvl2: [] } - - collections.forEach(({ handle }) => { - const path = categoryMap.get(handle) - if (path) { - if (path.length > 0 && !hierarchicalCategories.lvl0.includes(path[0])) { - hierarchicalCategories.lvl0.push(path[0]) - } - if (path.length > 1) { - const lvl1Path = path.slice(0, 2).join(separator) - if (!hierarchicalCategories.lvl1.includes(lvl1Path)) { - hierarchicalCategories.lvl1.push(lvl1Path) - } - } - if (path.length > 2) { - const lvl2Path = path.slice(0, 3).join(separator) - if (!hierarchicalCategories.lvl2.includes(lvl2Path)) { - hierarchicalCategories.lvl2.push(lvl2Path) - } - } - } - }) +function calculateCategoryDelta(categories: PlatformProduct["collections"], allIndexCategories: any[]) { + const allIndexCategoriesMap = new Map(allIndexCategories.map((category) => [category.id, category])) - return hierarchicalCategories + return categories.reduce>((acc, category) => { + const existingCategory = allIndexCategoriesMap.get(category.id) + if (!existingCategory || !isEqual(category, existingCategory)) { + acc.push(category) + } + return acc + }, []) } sync() diff --git a/starters/shopify-meilisearch/types/index.ts b/starters/shopify-meilisearch/types/index.ts index 490232a2..4e71e983 100644 --- a/starters/shopify-meilisearch/types/index.ts +++ b/starters/shopify-meilisearch/types/index.ts @@ -7,4 +7,9 @@ export type CommerceProduct = PlatformProduct & { avgRating?: number totalReviews?: number reviewsSummary?: string + hierarchicalCategories?: { + lvl0?: string[] + lvl1?: string[] + lvl2?: string[] + } } diff --git a/starters/shopify-meilisearch/utils/enrich-product.ts b/starters/shopify-meilisearch/utils/enrich-product.ts index 18cefe35..14f83661 100644 --- a/starters/shopify-meilisearch/utils/enrich-product.ts +++ b/starters/shopify-meilisearch/utils/enrich-product.ts @@ -1,46 +1,110 @@ import { generateImageCaption } from "lib/replicate" +import { Review } from "lib/reviews/types" import type { PlatformImage, PlatformMenu, PlatformProduct } from "lib/shopify/types" +import { CommerceProduct } from "types" +import { isOptIn } from "./opt-in" /* * Enrich product by attaching hierarchical categories to it * Takes in all tags, and tries to find hierarchy against it - * Currently just shopify focused */ -export const enrichProduct = async (product: PlatformProduct, collections: PlatformMenu["items"]) => { - const categoryMap = buildCategoryMap(collections) - const images = await generateProductAltTags(product) - const hierarchicalCategories = generateHierarchicalCategories(product.tags, categoryMap) +export class ProductEnrichmentBuilder { + private product: CommerceProduct - return { - ...product, - images: images.filter(Boolean), - hierarchicalCategories, + constructor(baseProduct: PlatformProduct) { + this.product = baseProduct + } + + withHierarchicalCategories(collections: PlatformMenu["items"], separator: string): this { + const categoryMap = buildCategoryMap(collections) + + if (!categoryMap.size) { + return this + } + + this.product = { + ...this.product, + hierarchicalCategories: generateHierarchicalCategories(this.product.tags, categoryMap, separator), + } + return this + } + + withReviews(allReviews: Review[]): this { + if (!isOptIn("reviews")) { + return this + } + + const productReviews = allReviews.filter((review) => review.product_handle === this.product.handle) + + if (productReviews.length) { + const avgRating = productReviews.reduce((acc, review) => acc + review.rating, 0) / productReviews.length || 0 + + this.product = { + ...this.product, + avgRating, + totalReviews: productReviews.length, + } + } + return this + } + + async withAltTags(): Promise { + if (!isOptIn("altTags")) { + return this + } + + try { + const images = await generateProductAltTags(this.product) + this.product = { + ...this.product, + images: images.filter(Boolean), + } + } catch (e) { + console.error(e) + } + return this + } + + build(): CommerceProduct { + return this.product } } -function buildCategoryMap(categories: PlatformMenu["items"]) { +async function generateProductAltTags(product: PlatformProduct): Promise<(PlatformImage | undefined)[]> { + try { + const altTagAwareImages = await Promise.all(product.images?.slice(0, 1).map(mapper).filter(Boolean)) + return [...altTagAwareImages, ...product.images?.slice(1)?.filter(Boolean)] || [] + } catch (e) { + console.error(e) + return product.images // graceful exit + } +} + +async function mapper(image: PlatformImage) { + const output = await generateImageCaption(image.url) + return { ...image, altText: output?.replace("Caption:", "") || "" } +} + +export function buildCategoryMap(items: PlatformMenu["items"]): Map { const categoryMap = new Map() - function traverse(items: PlatformMenu["items"], path: string[]) { + const traverse = (items: PlatformMenu["items"], path: string[]) => { for (const item of items) { - const newPath = [...path, item.resource!.handle] - categoryMap.set(item.resource!.handle, newPath) + const newPath = [...path, item.resource?.handle || ""] + categoryMap.set(item.resource?.handle || "", newPath) if (item.items && item.items.length > 0) { traverse(item.items, newPath) } } } - traverse(categories, []) - + traverse(items, []) return categoryMap } -function generateHierarchicalCategories(tags: PlatformProduct["tags"], categoryMap: Map) { - const hierarchicalCategories: Record<"lvl0" | "lvl1" | "lvl2", string[]> = { lvl0: [], lvl1: [], lvl2: [] } - - if (!tags.length || !categoryMap.size) return hierarchicalCategories +export function generateHierarchicalCategories(tags: CommerceProduct["tags"], categoryMap: Map, separator: string = " > ") { + const hierarchicalCategories: { lvl0: string[]; lvl1: string[]; lvl2: string[] } = { lvl0: [], lvl1: [], lvl2: [] } tags.forEach((tag) => { const path = categoryMap.get(tag) @@ -49,13 +113,13 @@ function generateHierarchicalCategories(tags: PlatformProduct["tags"], categoryM hierarchicalCategories.lvl0.push(path[0]) } if (path.length > 1) { - const lvl1Path = path.slice(0, 2).join(" > ") + const lvl1Path = path.slice(0, 2).join(separator) if (!hierarchicalCategories.lvl1.includes(lvl1Path)) { hierarchicalCategories.lvl1.push(lvl1Path) } } if (path.length > 2) { - const lvl2Path = path.slice(0, 3).join(" > ") + const lvl2Path = path.slice(0, 3).join(separator) if (!hierarchicalCategories.lvl2.includes(lvl2Path)) { hierarchicalCategories.lvl2.push(lvl2Path) } @@ -65,19 +129,3 @@ function generateHierarchicalCategories(tags: PlatformProduct["tags"], categoryM return hierarchicalCategories } - -async function generateProductAltTags(product: PlatformProduct): Promise<(PlatformImage | undefined)[]> { - try { - const altTagAwareImages = await Promise.all(product?.images?.slice(0, 1).map(mapper).filter(Boolean)) - return [...altTagAwareImages, ...product?.images?.slice(1)?.filter(Boolean)] || [] - } catch (e) { - console.error(e) - return product.images // graceful exit - } - - async function mapper(image: PlatformImage) { - const output = await generateImageCaption(image.url) - - return { ...image, altText: output?.replace("Caption:", "") || "" } - } -} diff --git a/starters/shopify-meilisearch/utils/opt-in.ts b/starters/shopify-meilisearch/utils/opt-in.ts index 417cd122..137624da 100644 --- a/starters/shopify-meilisearch/utils/opt-in.ts +++ b/starters/shopify-meilisearch/utils/opt-in.ts @@ -1,6 +1,6 @@ import { env } from "env.mjs" -type Feature = "reviews" | "ai-reviews" +type Feature = "reviews" | "ai-reviews" | "altTags" const features: Record> = { reviews: { @@ -11,6 +11,10 @@ const features: Record> = { message: "No keys provided for ai reviews summary feautre, to opt-in set envrioment variables: OpenAI API, JUDGE_API_TOKEN ", predicate: !!env.OPENAI_API_KEY, }, + altTags: { + message: "No keys provided for alt tags feature, to opt-in set environment variables: REPLICATE_API_KEY", + predicate: !!env.REPLICATE_API_KEY, + }, } const optInNotification = ({ message, source }: { message: string; source?: string }) => { @@ -21,7 +25,7 @@ const optInNotification = ({ message, source }: { message: string; source?: stri } export const isOptIn = (feature: Feature) => { - return features[feature].predicate + return !!features[feature].predicate } export const notifyOptIn = ({ feature, source }: { feature: Feature; source?: string }) => { diff --git a/starters/shopify-meilisearch/yarn.lock b/starters/shopify-meilisearch/yarn.lock index 3d33eee4..1032d110 100644 --- a/starters/shopify-meilisearch/yarn.lock +++ b/starters/shopify-meilisearch/yarn.lock @@ -6615,6 +6615,18 @@ glob@^10.3.10: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" +glob@^11.0.0: + version "11.0.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-11.0.0.tgz#6031df0d7b65eaa1ccb9b29b5ced16cea658e77e" + integrity sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g== + dependencies: + foreground-child "^3.1.0" + jackspeak "^4.0.1" + minimatch "^10.0.0" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^2.0.0" + glob@^7.1.1, glob@^7.1.3, glob@^7.1.4: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" @@ -7365,6 +7377,13 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jackspeak@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.0.2.tgz#11f9468a3730c6ff6f56823a820d7e3be9bef015" + integrity sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw== + dependencies: + "@isaacs/cliui" "^8.0.2" + jake@^10.8.5: version "10.9.2" resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.2.tgz#6ae487e6a69afec3a5e167628996b59f35ae2b7f" @@ -8113,6 +8132,11 @@ lru-cache@^10.2.0: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== +lru-cache@^11.0.0: + version "11.0.2" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.0.2.tgz#fbd8e7cf8211f5e7e5d91905c415a3f55755ca39" + integrity sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -8218,6 +8242,13 @@ minimatch@9.0.3: dependencies: brace-expansion "^2.0.1" +minimatch@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.1.tgz#ce0521856b453c86e25f2c4c0d03e6ff7ddc440b" + integrity sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ== + dependencies: + brace-expansion "^2.0.1" + minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" @@ -8754,6 +8785,14 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" +path-scurry@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.0.tgz#9f052289f23ad8bf9397a2a0425e7b8615c58580" + integrity sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -9400,6 +9439,14 @@ rimraf@^3.0.2: dependencies: glob "^7.1.3" +rimraf@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-6.0.1.tgz#ffb8ad8844dd60332ab15f52bc104bc3ed71ea4e" + integrity sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A== + dependencies: + glob "^11.0.0" + package-json-from-dist "^1.0.0" + rollup@^4.24.0: version "4.24.4" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.24.4.tgz#fdc76918de02213c95447c9ffff5e35dddb1d058"