Skip to content
104 changes: 51 additions & 53 deletions shopify/handlers/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,74 +3,72 @@ import { AppContext } from "../mod.ts";
import { withDigestCookie } from "../utils/password.ts";

type ConnInfo = Deno.ServeHandlerInfo;
const xmlHeader =
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';

const includeSiteMaps = (
currentXML: string,
origin: string,
includes?: string[],
) => {
const siteMapIncludeTags = [];
const XML_HEADER = '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
const TODAY = new Date().toISOString().substring(0, 10);

for (const include of (includes ?? [])) {
siteMapIncludeTags.push(`
<sitemap>
<loc>${include.startsWith("/") ? `${origin}${include}` : include}</loc>
<lastmod>${new Date().toISOString().substring(0, 10)}</lastmod>
</sitemap>`);
}
return siteMapIncludeTags.length > 0
? currentXML.replace(
xmlHeader,
`${xmlHeader}\n${siteMapIncludeTags.join("\n")}`,
)
: currentXML;
};
function buildIncludeSitemaps(origin: string, includes?: string[]) {
if (!includes?.length) return "";

return includes
.map((include) => {
const loc = include.startsWith("/") ? `${origin}${include}` : include;
return ` <sitemap>\n <loc>${loc}</loc>\n <lastmod>${TODAY}</lastmod>\n </sitemap>`;
})
.join("\n");
}

function excludeSitemaps(xml: string, origin: string, excludes?: string[]) {
if (!excludes?.length) return xml;

return xml.replace(
/<sitemap>\s*<loc>(.*?)<\/loc>[\s\S]*?<\/sitemap>/g,
(match, loc) => {
const locPath = loc.startsWith(origin)
? loc.slice(origin.length)
: new URL(loc).pathname;

return excludes.some((ex) => locPath.startsWith(ex)) ? "" : match;
},
);
}

export interface Props {
include?: string[];
exclude?: string[];
}

/**
* @title Sitemap Proxy
*/
export default function Sitemap(
{ include }: Props,
{ include, exclude }: Props,
appCtx: AppContext,
) {
const url = `https://${appCtx.storeName}.myshopify.com`;
return async (
req: Request,
ctx: ConnInfo,
) => {
if (!url) {
throw new Error("Missing publicUrl");
}

const publicUrl =
new URL(url?.startsWith("http") ? url : `https://${url}`).href;
const shopifyUrl = `https://${appCtx.storeName}.myshopify.com`;

const response = await Proxy({
url: publicUrl,
return async (req: Request, conn: ConnInfo) => {
const reqOrigin = new URL(req.url).origin;
const proxyResponse = await Proxy({
url: shopifyUrl,
customHeaders: withDigestCookie(appCtx),
})(req, ctx);
})(req, conn);

if (!proxyResponse.ok) return proxyResponse;

const originalXml = await proxyResponse.text();
const originWithSlash = reqOrigin.endsWith("/") ? reqOrigin.slice(0, -1) : reqOrigin;
const originReplacedXml = originalXml.replaceAll(shopifyUrl, originWithSlash);
const excludedXml = excludeSitemaps(originReplacedXml, reqOrigin, exclude);

if (!response.ok) {
return response;
}
const includeBlock = buildIncludeSitemaps(reqOrigin, include);
const finalXml = includeBlock
? excludedXml.replace(XML_HEADER, `${XML_HEADER}\n${includeBlock}`)
: excludedXml;

const reqUrl = new URL(req.url);
const text = await response.text();
return new Response(
includeSiteMaps(
text.replaceAll(publicUrl, `${reqUrl.origin}/`),
reqUrl.origin,
include,
),
{
headers: response.headers,
status: response.status,
},
);
return new Response(finalXml, {
headers: proxyResponse.headers,
status: proxyResponse.status,
});
};
}
22 changes: 18 additions & 4 deletions shopify/loaders/ProductDetailsPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import {
GetProductQuery,
GetProductQueryVariables,
HasMetafieldsMetafieldsArgs,
LanguageCode,
CountryCode
} from "../utils/storefront/storefront.graphql.gen.ts";
import { GetProduct } from "../utils/storefront/queries.ts";
import { Metafield } from "../utils/types.ts";
import { LanguageContextArgs, Metafield } from "../utils/types.ts";

export interface Props {
slug: RequestURLParam;
Expand All @@ -17,6 +19,18 @@ export interface Props {
* @description search for metafields
*/
metafields?: Metafield[];
/**
* @title Language Code
* @description Language code for the storefront API
* @example "EN" for English, "FR" for French, etc.
*/
languageCode?: LanguageCode;
/**
* @title Country Code
* @description Country code for the storefront API
* @example "US" for United States, "FR" for France, etc.
*/
countryCode?: CountryCode;
}

/**
Expand All @@ -29,7 +43,7 @@ const loader = async (
ctx: AppContext,
): Promise<ProductDetailsPage | null> => {
const { storefront } = ctx;
const { slug } = props;
const { slug, languageCode = "PT", countryCode = "BR" } = props;
const metafields = props.metafields || [];

const splitted = slug?.split("-");
Expand All @@ -39,9 +53,9 @@ const loader = async (

const data = await storefront.query<
GetProductQuery,
GetProductQueryVariables & HasMetafieldsMetafieldsArgs
GetProductQueryVariables & HasMetafieldsMetafieldsArgs & LanguageContextArgs
>({
variables: { handle, identifiers: metafields },
variables: { handle, identifiers: metafields, languageCode, countryCode },
...GetProduct,
});

Expand Down
26 changes: 24 additions & 2 deletions shopify/loaders/ProductList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
QueryRootCollectionArgs,
QueryRootSearchArgs,
SearchResultItemConnection,
LanguageCode,
CountryCode
} from "../utils/storefront/storefront.graphql.gen.ts";
import { toProduct } from "../utils/transform.ts";
import {
Expand All @@ -21,7 +23,7 @@ import {
searchSortShopify,
sortShopify,
} from "../utils/utils.ts";
import { Metafield } from "../utils/types.ts";
import { LanguageContextArgs, Metafield } from "../utils/types.ts";

export interface QueryProps {
/** @description search term to use on search */
Expand Down Expand Up @@ -70,6 +72,18 @@ export type Props = {
* @description search for metafields
*/
metafields?: Metafield[];
/**
* @title Language Code
* @description Language code for the storefront API
* @example "EN" for English, "FR" for French, etc.
*/
languageCode?: LanguageCode;
/**
* @title Country Code
* @description Country code for the storefront API
* @example "US" for United States, "FR" for France, etc.
*/
countryCode?: CountryCode;
};

// deno-lint-ignore no-explicit-any
Expand All @@ -92,6 +106,8 @@ const loader = async (

const count = props.count ?? 12;
const metafields = expandedProps.metafields || [];
const languageCode = expandedProps?.languageCode ?? "PT";
const countryCode = expandedProps?.countryCode ?? "BR";
Comment on lines +109 to +110
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor

🧩 Analysis chain

Verify the hardcoded Portuguese/Brazil defaults.

The defaults to "PT" (Portuguese) and "BR" (Brazil) may not be appropriate for all Shopify storefronts. Consider whether these should:

  • Default to undefined to let Shopify use the storefront's default locale
  • Be configurable at the app/store level
  • Have a documented business reason for the PT/BR choice

Hardcoded locale defaults could deliver incorrect localized content to international users.

Would you like to verify if there's a specific reason for the PT/BR defaults, or should these default to undefined instead?


🏁 Script executed:

#!/bin/bash
# Examine ProductList.ts file context around lines 109-110
cat shopify/loaders/ProductList.ts | head -130 | tail -50

Length of output: 1425


🏁 Script executed:

#!/bin/bash
# Search for expandedProps initialization and definition
rg -n "expandedProps" shopify/loaders/ProductList.ts -B5 -A5

Length of output: 2856


🏁 Script executed:

#!/bin/bash
# Search for any configuration or settings related to languageCode/countryCode
rg -n "languageCode|countryCode" shopify/loaders/ProductList.ts -B3 -A3

Length of output: 1177


🏁 Script executed:

#!/bin/bash
# Look for environment variables or config files that might set these defaults
fd -e "config\|env\|settings" -type f | head -20

Length of output: 170


🏁 Script executed:

#!/bin/bash
# Search for comments or documentation about PT/BR choice in the file
rg -n "PT|BR|Portuguese|Brazil|locale|language|country" shopify/loaders/ProductList.ts -B2 -A2

Length of output: 850


🏁 Script executed:

#!/bin/bash
# Search for README, docs, or comments explaining the PT/BR choice
find . -maxdepth 2 -type f \( -name "README*" -o -name "*.md" \) | xargs grep -l "PT\|BR\|Portuguese\|Brazil\|locale\|language\|country" 2>/dev/null | head -10

Length of output: 109


🏁 Script executed:

#!/bin/bash
# Search entire codebase for any configuration or context about locale defaults
rg -n "languageCode.*PT|countryCode.*BR|PT.*BR" --type-add "config:*.{json,yaml,yml,toml,env}" -t config -t ts -t js 2>/dev/null | head -20

Length of output: 92990


🏁 Script executed:

#!/bin/bash
# Check if there's an AppContext or global config that might set these
rg -n "AppContext|LanguageCode|CountryCode" shopify/ -l | head -10

Length of output: 299


Document or reconfigure the hardcoded PT/BR locale defaults.

The defaults for languageCode="PT" and countryCode="BR" are hardcoded with no documented business reason or configuration mechanism. While the interface design suggests these should be optional and configurable, there's no way for app or store-level settings to override them. This will cause incorrect localized content for non-Brazilian storefronts.

Either:

  • Add a config/environment way to override these defaults
  • Change defaults to undefined and rely on Shopify's storefront settings
  • Document why PT/BR is the required default for your use case


let shopifyProducts:
| SearchResultItemConnection
Expand Down Expand Up @@ -119,15 +135,18 @@ const loader = async (
});

if (isQueryList(props)) {

const data = await storefront.query<
QueryRoot,
QueryRootSearchArgs & HasMetafieldsMetafieldsArgs
QueryRootSearchArgs & HasMetafieldsMetafieldsArgs & LanguageContextArgs
>({
variables: {
first: count,
query: props.query,
productFilters: filters,
identifiers: metafields,
languageCode,
countryCode,
...searchSortShopify[sort],
},
...SearchProducts,
Expand All @@ -139,12 +158,15 @@ const loader = async (
& QueryRootCollectionArgs
& CollectionProductsArgs
& HasMetafieldsMetafieldsArgs
& LanguageContextArgs
>({
variables: {
first: count,
handle: props.collection,
filters,
identifiers: metafields,
languageCode,
countryCode,
...sortShopify[sort],
},
...ProductsByCollection,
Expand Down
36 changes: 30 additions & 6 deletions shopify/loaders/ProductListingPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import {
QueryRootCollectionArgs,
QueryRootSearchArgs,
SearchResultItemConnection,
LanguageCode,
CountryCode
} from "../utils/storefront/storefront.graphql.gen.ts";
import { toFilter, toProduct } from "../utils/transform.ts";
import { Metafield } from "../utils/types.ts";
import { LanguageContextArgs, Metafield } from "../utils/types.ts";
import {
getFiltersByUrl,
searchSortOptions,
Expand Down Expand Up @@ -69,6 +71,18 @@ export interface Props {
* @description The URL of the page, used to override URL from request
*/
pageHref?: string;
/**
* @title Language Code
* @description Language code for the storefront API
* @example "EN" for English, "FR" for French, etc.
*/
languageCode?: LanguageCode;
/**
* @title Country Code
* @description Country code for the storefront API
* @example "US" for United States, "FR" for France, etc.
*/
countryCode?: CountryCode;
}

/**
Expand All @@ -94,6 +108,8 @@ const loader = async (
const startCursor = props.startCursor ||
url.searchParams.get("startCursor") || "";
const metafields = props.metafields || [];
const languageCode = props?.languageCode || "PT";
const countryCode = props?.countryCode || "BR";

const isSearch = Boolean(query);
let hasNextPage = false;
Expand All @@ -105,6 +121,7 @@ const loader = async (
| undefined = undefined;
let shopifyFilters = undefined;
let records = undefined;
let collectionId = undefined;
let collectionTitle = undefined;
let collectionDescription = undefined;

Expand All @@ -113,7 +130,7 @@ const loader = async (
if (isSearch) {
const data = await storefront.query<
QueryRoot,
QueryRootSearchArgs & HasMetafieldsMetafieldsArgs
QueryRootSearchArgs & HasMetafieldsMetafieldsArgs & LanguageContextArgs
>({
variables: {
...(!endCursor && { first: count }),
Expand All @@ -123,6 +140,8 @@ const loader = async (
query: query,
productFilters: getFiltersByUrl(url),
identifiers: metafields,
languageCode,
countryCode,
...searchSortShopify[sort],
},
...SearchProducts,
Expand All @@ -136,15 +155,16 @@ const loader = async (
data?.search?.pageInfo.hasPreviousPage ?? false,
);
} else {
// TODO: understand how accept more than one path
// example: /collections/first-collection/second-collection
const pathname = props.collectionName || url.pathname.split("/")[1];
// Support for multiple paths, such as /{lang}/collections/first-collection/second-collection
// Always takes the last non-empty segment as pathname
const pathname = props.collectionName || url.pathname.split("/").filter(Boolean).pop();

const data = await storefront.query<
QueryRoot,
& QueryRootCollectionArgs
& CollectionProductsArgs
& HasMetafieldsMetafieldsArgs
& LanguageContextArgs
>({
variables: {
...(!endCursor && { first: count }),
Expand All @@ -154,6 +174,8 @@ const loader = async (
identifiers: metafields,
handle: pathname,
filters: getFiltersByUrl(url),
languageCode,
countryCode,
...sortShopify[sort],
},
...ProductsByCollection,
Expand All @@ -167,6 +189,7 @@ const loader = async (
hasPreviousPage = Boolean(
data?.collection?.products.pageInfo.hasPreviousPage ?? false,
);
collectionId = data.collection?.id;
collectionTitle = data.collection?.title;
collectionDescription = data.collection?.description;
}
Expand Down Expand Up @@ -201,9 +224,10 @@ const loader = async (
// TODO: Update breadcrumb when accept more than one path
breadcrumb: {
"@type": "BreadcrumbList",
"@id": collectionId,
itemListElement: [{
"@type": "ListItem" as const,
name: isSearch ? query : url.pathname.split("/")[1],
name: isSearch ? query : url.pathname.split("/").filter(Boolean).pop(),
item: isSearch ? url.href : url.pathname,
position: 2,
}],
Expand Down
Loading
Loading