diff --git a/templates/hello-world/storefrontapi.generated.d.ts b/templates/hello-world/storefrontapi.generated.d.ts new file mode 100644 index 0000000000..39e6dd17ac --- /dev/null +++ b/templates/hello-world/storefrontapi.generated.d.ts @@ -0,0 +1,24 @@ +/* eslint-disable eslint-comments/disable-enable-pair */ +/* eslint-disable eslint-comments/no-unlimited-disable */ +/* eslint-disable */ +import * as StorefrontAPI from '@shopify/hydrogen/storefront-api-types'; + +export type LayoutQueryVariables = StorefrontAPI.Exact<{[key: string]: never}>; + +export type LayoutQuery = { + shop: Pick; +}; + +interface GeneratedQueryTypes { + '#graphql\n query layout {\n shop {\n name\n description\n }\n }\n': { + return: LayoutQuery; + variables: LayoutQueryVariables; + }; +} + +interface GeneratedMutationTypes {} + +declare module '@shopify/hydrogen' { + interface StorefrontQueries extends GeneratedQueryTypes {} + interface StorefrontMutations extends GeneratedMutationTypes {} +} diff --git a/templates/skeleton/app/routes/[sitemap.xml].tsx b/templates/skeleton/app/routes/[sitemap.xml].tsx index be4ab94f33..42ffb9acd8 100644 --- a/templates/skeleton/app/routes/[sitemap.xml].tsx +++ b/templates/skeleton/app/routes/[sitemap.xml].tsx @@ -1,6 +1,6 @@ import {flattenConnection} from '@shopify/hydrogen'; import type {LoaderFunctionArgs} from '@shopify/remix-oxygen'; -import type {SitemapQuery} from 'storefrontapi.generated'; +// import type {SitemapQuery} from 'storefrontapi.generated'; /** * the google limit is 50K, however, the storefront API @@ -8,24 +8,12 @@ import type {SitemapQuery} from 'storefrontapi.generated'; */ const MAX_URLS = 250; -type Entry = { - url: string; - lastMod?: string; - changeFreq?: string; - image?: { - url: string; - title?: string; - caption?: string; - }; -}; - export async function loader({ request, context: {storefront}, }: LoaderFunctionArgs) { const data = await storefront.query(SITEMAP_QUERY, { variables: { - urlLimits: MAX_URLS, language: storefront.i18n.language, }, }); @@ -45,6 +33,54 @@ export async function loader({ }); } +// export async function loader({ +// request, +// context: {storefront}, +// }: LoaderFunctionArgs) { +// // Hardcoded JSON data +// const data = { +// data: { +// sitemapIndex: { +// sitemaps: [ +// { +// __typename: 'SitemapIndexEntry', +// lastmod: '2024-03-20 13:40:05 +0000', +// loc: 'sitemap_products_1.xml?from=1&to=2', +// }, +// { +// __typename: 'SitemapIndexEntry', +// lastmod: '2024-03-20 13:40:05 +0000', +// loc: 'sitemap_pages_1.xml', +// }, +// { +// __typename: 'SitemapIndexEntry', +// lastmod: '2024-03-20 13:40:05 +0000', +// loc: 'sitemap_collections_1.xml', +// }, +// { +// __typename: 'SitemapIndexEntry', +// lastmod: '2024-03-20 13:40:05 +0000', +// loc: 'sitemap_blogs_1.xml', +// }, +// ], +// }, +// }, +// }; + +// if (!data) { +// throw new Response('No data found', {status: 404}); +// } + +// const sitemap = generateSitemap({data, baseUrl: new URL(request.url).origin}); + +// return new Response(sitemap, { +// headers: { +// 'Content-Type': 'application/xml', +// 'Cache-Control': `max-age=${60 * 60 * 24}`, +// }, +// }); +// } + function xmlEncode(string: string) { return string.replace(/[&<>'"]/g, (char) => `&#${char.charCodeAt(0)};`); } @@ -53,124 +89,54 @@ function generateSitemap({ data, baseUrl, }: { - data: SitemapQuery; + data: Record; baseUrl: string; }) { - const products = flattenConnection(data.products) - .filter((product) => product.onlineStoreUrl) - .map((product) => { - const url = `${baseUrl}/products/${xmlEncode(product.handle)}`; - - const productEntry: Entry = { - url, - lastMod: product.updatedAt, - changeFreq: 'daily', - }; - - if (product.featuredImage?.url) { - productEntry.image = { - url: xmlEncode(product.featuredImage.url), - }; - - if (product.title) { - productEntry.image.title = xmlEncode(product.title); - } - - if (product.featuredImage.altText) { - productEntry.image.caption = xmlEncode(product.featuredImage.altText); - } - } - - return productEntry; - }); - - const collections = flattenConnection(data.collections) - .filter((collection) => collection.onlineStoreUrl) - .map((collection) => { - const url = `${baseUrl}/collections/${collection.handle}`; - - return { - url, - lastMod: collection.updatedAt, - changeFreq: 'daily', - }; - }); - - const pages = flattenConnection(data.pages) - .filter((page) => page.onlineStoreUrl) - .map((page) => { - const url = `${baseUrl}/pages/${page.handle}`; - - return { - url, - lastMod: page.updatedAt, - changeFreq: 'weekly', - }; - }); + const urls = data.sitemapIndex.sitemaps.map((sitemap: any) => { + const url = `${baseUrl}/sitemap${sitemap.loc}`; + const lastMod = sitemap.lastmod; - const urls = [...products, ...collections, ...pages]; - - return ` - - ${urls.map(renderUrlTag).join('')} - `; -} + return {url, lastMod}; + }); -function renderUrlTag({url, lastMod, changeFreq, image}: Entry) { - const imageTag = image - ? ` - ${image.url} - ${image.title ?? ''} - ${image.caption ?? ''} - `.trim() - : ''; + // return ` + // + // ${urls + // .map(({url, lastMod}) => { + // return ` + // + // ${url} + // ${lastMod} + // + // `; + // }) + // .join('')} + // + // `; return ` - - ${url} - ${lastMod} - ${changeFreq} - ${imageTag} - - `.trim(); + + ${urls + .map(({url, lastMod}) => { + return ` + + ${url} + + `; + }) + .join('')} + + `; } const SITEMAP_QUERY = `#graphql - query Sitemap($urlLimits: Int, $language: LanguageCode) + query Sitemap($language: LanguageCode) @inContext(language: $language) { - products( - first: $urlLimits - query: "published_status:'online_store:visible'" - ) { - nodes { - updatedAt - handle - onlineStoreUrl - title - featuredImage { - url - altText - } - } - } - collections( - first: $urlLimits - query: "published_status:'online_store:visible'" - ) { - nodes { - updatedAt - handle - onlineStoreUrl - } - } - pages(first: $urlLimits, query: "published_status:'published'") { - nodes { - updatedAt - handle - onlineStoreUrl + sitemapIndex { + sitemaps { + __typename + loc + lastmod } } } diff --git a/templates/skeleton/app/routes/[sitemap_old.xml].tsx b/templates/skeleton/app/routes/[sitemap_old.xml].tsx new file mode 100644 index 0000000000..be4ab94f33 --- /dev/null +++ b/templates/skeleton/app/routes/[sitemap_old.xml].tsx @@ -0,0 +1,177 @@ +import {flattenConnection} from '@shopify/hydrogen'; +import type {LoaderFunctionArgs} from '@shopify/remix-oxygen'; +import type {SitemapQuery} from 'storefrontapi.generated'; + +/** + * the google limit is 50K, however, the storefront API + * allows querying only 250 resources per pagination page + */ +const MAX_URLS = 250; + +type Entry = { + url: string; + lastMod?: string; + changeFreq?: string; + image?: { + url: string; + title?: string; + caption?: string; + }; +}; + +export async function loader({ + request, + context: {storefront}, +}: LoaderFunctionArgs) { + const data = await storefront.query(SITEMAP_QUERY, { + variables: { + urlLimits: MAX_URLS, + language: storefront.i18n.language, + }, + }); + + if (!data) { + throw new Response('No data found', {status: 404}); + } + + const sitemap = generateSitemap({data, baseUrl: new URL(request.url).origin}); + + return new Response(sitemap, { + headers: { + 'Content-Type': 'application/xml', + + 'Cache-Control': `max-age=${60 * 60 * 24}`, + }, + }); +} + +function xmlEncode(string: string) { + return string.replace(/[&<>'"]/g, (char) => `&#${char.charCodeAt(0)};`); +} + +function generateSitemap({ + data, + baseUrl, +}: { + data: SitemapQuery; + baseUrl: string; +}) { + const products = flattenConnection(data.products) + .filter((product) => product.onlineStoreUrl) + .map((product) => { + const url = `${baseUrl}/products/${xmlEncode(product.handle)}`; + + const productEntry: Entry = { + url, + lastMod: product.updatedAt, + changeFreq: 'daily', + }; + + if (product.featuredImage?.url) { + productEntry.image = { + url: xmlEncode(product.featuredImage.url), + }; + + if (product.title) { + productEntry.image.title = xmlEncode(product.title); + } + + if (product.featuredImage.altText) { + productEntry.image.caption = xmlEncode(product.featuredImage.altText); + } + } + + return productEntry; + }); + + const collections = flattenConnection(data.collections) + .filter((collection) => collection.onlineStoreUrl) + .map((collection) => { + const url = `${baseUrl}/collections/${collection.handle}`; + + return { + url, + lastMod: collection.updatedAt, + changeFreq: 'daily', + }; + }); + + const pages = flattenConnection(data.pages) + .filter((page) => page.onlineStoreUrl) + .map((page) => { + const url = `${baseUrl}/pages/${page.handle}`; + + return { + url, + lastMod: page.updatedAt, + changeFreq: 'weekly', + }; + }); + + const urls = [...products, ...collections, ...pages]; + + return ` + + ${urls.map(renderUrlTag).join('')} + `; +} + +function renderUrlTag({url, lastMod, changeFreq, image}: Entry) { + const imageTag = image + ? ` + ${image.url} + ${image.title ?? ''} + ${image.caption ?? ''} + `.trim() + : ''; + + return ` + + ${url} + ${lastMod} + ${changeFreq} + ${imageTag} + + `.trim(); +} + +const SITEMAP_QUERY = `#graphql + query Sitemap($urlLimits: Int, $language: LanguageCode) + @inContext(language: $language) { + products( + first: $urlLimits + query: "published_status:'online_store:visible'" + ) { + nodes { + updatedAt + handle + onlineStoreUrl + title + featuredImage { + url + altText + } + } + } + collections( + first: $urlLimits + query: "published_status:'online_store:visible'" + ) { + nodes { + updatedAt + handle + onlineStoreUrl + } + } + pages(first: $urlLimits, query: "published_status:'published'") { + nodes { + updatedAt + handle + onlineStoreUrl + } + } + } +` as const; diff --git a/templates/skeleton/app/routes/sitemap.$handle[.xml].tsx b/templates/skeleton/app/routes/sitemap.$handle[.xml].tsx new file mode 100644 index 0000000000..e7dc02a6ae --- /dev/null +++ b/templates/skeleton/app/routes/sitemap.$handle[.xml].tsx @@ -0,0 +1,142 @@ +import {redirect, json, type LoaderFunctionArgs} from '@shopify/remix-oxygen'; +import {useLoaderData, type MetaFunction} from '@remix-run/react'; + +export const meta: MetaFunction = ({data}) => { + return [{title: `Hydrogen | sitemap`}]; +}; + +export async function loader({ + request, + params, + context: {storefront}, +}: LoaderFunctionArgs) { + const {handle} = params; + if (!handle) { + return redirect('/sitemap.xml'); + } + + const [sitemapType, page] = handle.split('_'); + const url = new URL(request.url); + const from = url.searchParams.get('from'); + const to = url.searchParams.get('to'); + + if (!sitemapType || !page) { + return redirect('/sitemap.xml'); + } + + let sitemapTypeGl; + + switch (sitemapType) { + case 'products': + sitemapTypeGl = 'SITEMAP_PRODUCT'; + break; + case 'pages': + sitemapTypeGl = 'SITEMAP_PAGE'; + break; + case 'collections': + sitemapTypeGl = 'SITEMAP_COLLECTION'; + break; + case 'blogs': + sitemapTypeGl = 'SITEMAP_BLOG'; + break; + default: + return redirect('/sitemap.xml'); + } + + console.log(sitemapTypeGl, page, from, to); + + const data = await storefront.query(SITEMAP_QUERY, { + variables: { + page: parseInt(page), + sitemapType: sitemapTypeGl, + from: from ? parseInt(from) : undefined, + to: to ? parseInt(to) : undefined, + language: storefront.i18n.language, + }, + }); + + if (!data) { + throw new Response('No data found', {status: 404}); + } + + const sitemap = generateSitemap({data, baseUrl: new URL(request.url).origin}); + + return new Response(sitemap, { + headers: { + 'Content-Type': 'application/xml', + + 'Cache-Control': `max-age=${60 * 60 * 24}`, + }, + }); +} + +function generateSitemap({ + data, + baseUrl, +}: { + data: Record; + baseUrl: string; +}) { + const urls = data.sitemap.map((sitemap: any) => { + const loc = sitemap.loc === '/' ? baseUrl : `${baseUrl}${sitemap.loc}`; + const lastmod = sitemap.lastmod; + const changefreq = sitemap.changefreq; + const image = sitemap.image; + if (image) { + image.loc = `${baseUrl}${stripUrl(image.loc)}`; + } + + + return {loc, lastmod, changefreq, image}; + }); + + return ` + + ${urls.map(renderUrlTag).join('')} + `; +} + +function stripUrl(url) { + const baseUrl = 'https://cdn.shopify.'; + const endUrl = '.spin.dev'; + const regex = new RegExp(`${baseUrl}.*${endUrl}`, 'g'); + return url.replace(regex, ''); +} + +function renderUrlTag({loc, lastmod, changefreq, image}) { + const imageTag = image + ? ` + ${image.loc} + ${image.title ?? ''} + ${image.caption ?? ''} + `.trim() + : ''; + + return ` + + ${loc} + ${lastmod ? `${lastmod}` : ''} + ${changefreq} + ${imageTag} + + `.trim(); +} + +const SITEMAP_QUERY = `#graphql + query Sitemap($language: LanguageCode, $page: Int!, $from: Int, $to: Int, $sitemapType: String!) + @inContext(language: $language) { + sitemap(page: $page, type: $sitemapType, from: $from, to: $to) { + changefreq + lastmod + loc + image { + caption + loc + title + } + } + } +` as const; diff --git a/templates/skeleton/package.json b/templates/skeleton/package.json index 5374669b3d..9b19962998 100644 --- a/templates/skeleton/package.json +++ b/templates/skeleton/package.json @@ -6,7 +6,7 @@ "type": "commonjs", "scripts": { "build": "shopify hydrogen build", - "dev": "shopify hydrogen dev --codegen --customer-account-push", + "dev": "shopify hydrogen dev --codegen", "preview": "npm run build && shopify hydrogen preview", "lint": "eslint --no-error-on-unmatched-pattern --ext .js,.ts,.jsx,.tsx .", "typecheck": "tsc --noEmit",