Skip to content
135 changes: 80 additions & 55 deletions shopify/handlers/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,74 +3,99 @@ 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 = [];

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;
};

const XML_HEADER = '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">';
const TODAY = new Date().toISOString().substring(0, 10);

function buildIncludeSitemaps(origin: string, includes?: string[]) {
if (!includes?.length) return "";

const today = new Date().toISOString().slice(0, 10);
const esc = (s: string) =>
s
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");

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

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

// Ensure all exclude prefixes start with a slash
const normalized = excludes.map((ex) => (ex.startsWith("/") ? ex : `/${ex}`));

return xml.replace(
/<sitemap>\s*<loc>(.*?)<\/loc>[\s\S]*?<\/sitemap>/g,
(match, loc) => {
let locPath: string;
try {
// Use origin as base to support both absolute and relative URLs
const u = new URL(loc, origin);
locPath = u.pathname;
} catch {
// If URL parsing fails, leave the sitemap entry untouched
return match;
}

return normalized.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 (!response.ok) {
return response;
}
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);

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,
},
);
const includeBlock = buildIncludeSitemaps(reqOrigin, include);
const finalXml = includeBlock
? excludedXml.replace(XML_HEADER, `${XML_HEADER}\n${includeBlock}`)
: excludedXml;

const headers = new Headers(proxyResponse.headers);
headers.delete("content-length");
headers.delete("content-encoding");
headers.delete("etag");
headers.delete("accept-ranges");
if (!headers.get("content-type")?.includes("xml")) {
headers.set("content-type", "application/xml; charset=utf-8");
}
return new Response(finalXml, {
status: proxyResponse.status,
headers,
});
};
}
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
Loading
Loading