diff --git a/.changeset/silent-pianos-battle.md b/.changeset/silent-pianos-battle.md new file mode 100644 index 0000000000..0e323a1a44 --- /dev/null +++ b/.changeset/silent-pianos-battle.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-hydrogen': patch +--- + +Fix bug in CLI not recognising the --install-deps flag when creating projects diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e20451a81..a4d91ab397 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,16 +72,18 @@ jobs: - name: 📥 Install dependencies run: npm ci - - name: 💾 Turbo cache - id: turbo-cache - uses: actions/cache@v3 - with: - path: | - node_modules/.cache/turbo - **/.turbo - key: turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} - restore-keys: | - turbo-${{ github.job }}-${{ github.ref_name }}- + # Enabling the turbo cache causes deployments to fail intermittently. + # The build step fails with dependency issues. More investigation needed. + # - name: 💾 Turbo cache + # id: turbo-cache + # uses: actions/cache@v3 + # with: + # path: | + # node_modules/.cache/turbo + # **/.turbo + # key: turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} + # restore-keys: | + # turbo-${{ github.job }}-${{ github.ref_name }}- - name: 📦 Build packages run: npm run build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c39d0f3b82..20fd750e2e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -29,16 +29,18 @@ jobs: - name: 📥 Install dependencies run: npm ci - - name: 💾 Turbo cache - id: turbo-cache - uses: actions/cache@v3 - with: - path: | - node_modules/.cache/turbo - **/.turbo - key: turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} - restore-keys: | - turbo-${{ github.job }}-${{ github.ref_name }}- + # Enabling the turbo cache causes deployments to fail intermittently. + # The build step fails with dependency issues. More investigation needed. + # - name: 💾 Turbo cache + # id: turbo-cache + # uses: actions/cache@v3 + # with: + # path: | + # node_modules/.cache/turbo + # **/.turbo + # key: turbo-${{ github.job }}-${{ github.ref_name }}-${{ github.sha }} + # restore-keys: | + # turbo-${{ github.job }}-${{ github.ref_name }}- - name: 📦 Build packages run: | diff --git a/package-lock.json b/package-lock.json index d487992d9d..e0549cea5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27072,7 +27072,8 @@ "version": "2023.1.5", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { - "@shopify/hydrogen-react": "2023.1.6" + "@shopify/hydrogen-react": "2023.1.6", + "react": "^18.2.0" }, "devDependencies": { "@testing-library/react": "^14.0.0", diff --git a/packages/cli/src/commands/hydrogen/init.test.ts b/packages/cli/src/commands/hydrogen/init.test.ts new file mode 100644 index 0000000000..75e5fbf94c --- /dev/null +++ b/packages/cli/src/commands/hydrogen/init.test.ts @@ -0,0 +1,93 @@ +import {describe, it, expect, vi, beforeEach} from 'vitest'; +import {temporaryDirectoryTask} from 'tempy'; +import {runInit} from './init.js'; +import {ui} from '@shopify/cli-kit'; +import {installNodeModules} from '@shopify/cli-kit/node/node-package-manager'; + +describe('init', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mock('@shopify/cli-kit'); + vi.mock('../../utils/transpile-ts.js'); + vi.mock('../../utils/template-downloader.js', async () => ({ + getLatestTemplates: () => Promise.resolve({}), + })); + vi.mock('@shopify/cli-kit/node/node-package-manager'); + vi.mocked(ui.prompt).mockImplementation(() => + Promise.resolve({installDeps: 'false'}), + ); + }); + + const defaultOptions = (stubs: Record) => ({ + template: 'hello-world', + language: 'js', + path: 'path/to/project', + ...stubs, + }); + + describe('installDeps', () => { + it('prompts the user to install dependencies when installDeps is not passed', async () => { + await temporaryDirectoryTask(async (tmpDir) => { + // Given + const options = defaultOptions({path: tmpDir}); + + vi.mocked(ui.prompt).mockImplementation(() => + Promise.resolve({installDeps: 'false'}), + ); + + // When + await runInit(options); + + // Then + expect(ui.prompt).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + name: 'installDeps', + }), + ]), + ); + expect(installNodeModules).not.toHaveBeenCalled(); + }); + }); + + it('does not prompt the user to install dependencies when installDeps is true', async () => { + await temporaryDirectoryTask(async (tmpDir) => { + // Given + const options = defaultOptions({installDeps: true, path: tmpDir}); + + // When + await runInit(options); + + // Then + expect(ui.prompt).not.toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + name: 'installDeps', + }), + ]), + ); + expect(installNodeModules).toHaveBeenCalled(); + }); + }); + + it('does not show a prompt to install dependencies when installDeps is false', async () => { + await temporaryDirectoryTask(async (tmpDir) => { + // Given + const options = defaultOptions({installDeps: false, path: tmpDir}); + + // When + await runInit(options); + + // Then + expect(ui.prompt).not.toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + name: 'installDeps', + }), + ]), + ); + expect(installNodeModules).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/packages/cli/src/commands/hydrogen/init.ts b/packages/cli/src/commands/hydrogen/init.ts index 747edc55e2..861f9f5c31 100644 --- a/packages/cli/src/commands/hydrogen/init.ts +++ b/packages/cli/src/commands/hydrogen/init.ts @@ -6,7 +6,11 @@ import { import {renderFatalError} from '@shopify/cli-kit/node/ui'; import Flags from '@oclif/core/lib/flags.js'; import {output, path} from '@shopify/cli-kit'; -import {commonFlags, parseProcessFlags} from '../../utils/flags.js'; +import { + commonFlags, + parseProcessFlags, + flagsToCamelObject, +} from '../../utils/flags.js'; import {transpileProject} from '../../utils/transpile-ts.js'; import {getLatestTemplates} from '../../utils/template-downloader.js'; import {checkHydrogenVersion} from '../../utils/check-version.js'; @@ -46,7 +50,7 @@ export default class Init extends Command { // @ts-ignore const {flags} = await this.parse(Init); - await runInit({...flags}); + await runInit(flagsToCamelObject(flags)); } } diff --git a/templates/demo-store/app/components/Hero.tsx b/templates/demo-store/app/components/Hero.tsx index 2015ac68f1..dfe293ea4a 100644 --- a/templates/demo-store/app/components/Hero.tsx +++ b/templates/demo-store/app/components/Hero.tsx @@ -62,6 +62,7 @@ export function Hero({ widths={[450, 700]} width={375} data={spreadSecondary.reference as Media} + loading={loading} /> )} diff --git a/templates/demo-store/app/lib/seo.server.ts b/templates/demo-store/app/lib/seo.server.ts new file mode 100644 index 0000000000..42a946b5ad --- /dev/null +++ b/templates/demo-store/app/lib/seo.server.ts @@ -0,0 +1,449 @@ +import {type SeoConfig} from '@shopify/hydrogen'; +import type { + Article, + Blog, + Collection, + CollectionConnection, + Page, + Product, + ProductVariant, + ShopPolicy, + Shop, +} from '@shopify/hydrogen-react/storefront-api-types'; +import type { + Article as SeoArticle, + BreadcrumbList, + Blog as SeoBlog, + CollectionPage, + Offer, + Organization, + Product as SeoProduct, + WebPage, +} from 'schema-dts'; + +function root({ + shop, + url, +}: { + shop: Shop; + url: Request['url']; +}): SeoConfig { + return { + title: shop?.name, + titleTemplate: '%s | Hydrogen Demo Store', + description: truncate(shop?.description ?? ''), + handle: '@shopify', + url, + robots: { + noIndex: false, + noFollow: false, + }, + jsonLd: { + '@context': 'https://schema.org', + '@type': 'Organization', + name: shop.name, + logo: shop.brand?.logo?.image?.url, + sameAs: [ + 'https://twitter.com/shopify', + 'https://facebook.com/shopify', + 'https://instagram.com/shopify', + 'https://youtube.com/shopify', + 'https://tiktok.com/@shopify', + ], + url, + potentialAction: { + '@type': 'SearchAction', + target: `${url}search?q={search_term}`, + query: "required name='search_term'", + }, + }, + }; +} + +function home(): SeoConfig { + return { + title: 'Home', + titleTemplate: '%s | Hydrogen Demo Store', + description: 'The best place to buy snowboarding products', + robots: { + noIndex: false, + noFollow: false, + }, + jsonLd: { + '@context': 'https://schema.org', + '@type': 'WebPage', + name: 'Home page', + }, + }; +} + +function productJsonLd({ + product, + selectedVariant, + url, +}: { + product: Product; + selectedVariant: ProductVariant; + url: Request['url']; +}): SeoConfig['jsonLd'][] { + const origin = new URL(url).origin; + const variants = product.variants.nodes; + const description = truncate( + product?.seo?.description ?? product?.description, + ); + const offers: Offer[] = (variants || []).map((variant) => { + const variantUrl = new URL(url); + for (const option of variant.selectedOptions) { + variantUrl.searchParams.set(option.name, option.value); + } + const availability = variant.availableForSale + ? 'https://schema.org/InStock' + : 'https://schema.org/OutOfStock'; + + return { + '@type': 'Offer', + availability, + price: parseFloat(variant.price.amount), + priceCurrency: variant.price.currencyCode, + sku: variant?.sku ?? '', + url: variantUrl.toString(), + }; + }); + return [ + { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: 'Products', + item: `${origin}/products`, + }, + { + '@type': 'ListItem', + position: 2, + name: product.title, + }, + ], + }, + { + '@context': 'https://schema.org', + '@type': 'Product', + brand: { + '@type': 'Brand', + name: product.vendor, + }, + description, + image: [selectedVariant?.image?.url ?? ''], + name: product.title, + offers, + sku: selectedVariant?.sku ?? '', + url, + }, + ]; +} + +function product({ + product, + url, + selectedVariant, +}: { + product: Product; + selectedVariant: ProductVariant; + url: Request['url']; +}) { + const description = truncate( + product?.seo?.description ?? product?.description ?? '', + ); + return { + title: product?.seo?.title ?? product?.title, + description, + media: selectedVariant?.image, + jsonLd: productJsonLd({product, selectedVariant, url}), + }; +} + +function collectionJsonLd({ + url, + collection, +}: { + url: Request['url']; + collection: Collection; +}): SeoConfig['jsonLd'][] { + const siteUrl = new URL(url); + const itemListElement: CollectionPage['mainEntity'] = + collection.products.nodes.map((product, index) => { + return { + '@type': 'ListItem', + position: index + 1, + url: `/products/${product.handle}`, + }; + }); + + return [ + { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: 'Collections', + item: `${siteUrl.host}/collections`, + }, + { + '@type': 'ListItem', + position: 2, + name: collection.title, + }, + ], + }, + { + '@context': 'https://schema.org', + '@type': 'CollectionPage', + name: collection?.seo?.title ?? collection?.title ?? '', + description: truncate( + collection?.seo?.description ?? collection?.description ?? '', + ), + image: collection?.image?.url, + url: `/collections/${collection.handle}`, + mainEntity: { + '@type': 'ItemList', + itemListElement, + }, + }, + ]; +} + +function collection({ + collection, + url, +}: { + collection: Collection; + url: Request['url']; +}) { + return { + title: collection?.seo?.title, + description: truncate( + collection?.seo?.description ?? collection?.description ?? '', + ), + titleTemplate: '%s | Collection', + media: { + type: 'image', + url: collection?.image?.url, + height: collection?.image?.height, + width: collection?.image?.width, + altText: collection?.image?.altText, + }, + jsonLd: collectionJsonLd({collection, url}), + }; +} + +function collectionsJsonLd({ + url, + collections, +}: { + url: Request['url']; + collections: CollectionConnection; +}): SeoConfig['jsonLd'] { + const itemListElement: CollectionPage['mainEntity'] = collections.nodes.map( + (collection, index) => { + return { + '@type': 'ListItem', + position: index + 1, + url: `/collections/${collection.handle}`, + }; + }, + ); + + return { + '@context': 'https://schema.org', + '@type': 'CollectionPage', + name: 'Collections', + description: 'All collections', + url, + mainEntity: { + '@type': 'ItemList', + itemListElement, + }, + }; +} + +function listCollections({ + collections, + url, +}: { + collections: CollectionConnection; + url: Request['url']; +}): SeoConfig { + return { + title: 'Collections', + titleTemplate: '%s | Collections', + description: 'All hydrogen collections', + url, + jsonLd: collectionsJsonLd({collections, url}), + }; +} + +function article({ + article, + url, +}: { + article: Article; + url: Request['url']; +}): SeoConfig { + return { + title: article?.seo?.title ?? article?.title, + description: truncate(article?.seo?.description ?? ''), + titleTemplate: '%s | Journal', + url, + media: { + type: 'image', + url: article?.image?.url, + height: article?.image?.height, + width: article?.image?.width, + altText: article?.image?.altText, + }, + jsonLd: { + '@context': 'https://schema.org', + '@type': 'Article', + alternativeHeadline: article.title, + articleBody: article.contentHtml, + datePublished: article?.publishedAt, + description: truncate( + article?.seo?.description || article?.excerpt || '', + ), + headline: article?.seo?.title || '', + image: article?.image?.url, + url, + }, + }; +} + +function blog({ + blog, + url, +}: { + blog: Blog; + url: Request['url']; +}): SeoConfig { + return { + title: blog?.seo?.title, + description: truncate(blog?.seo?.description || ''), + titleTemplate: '%s | Blog', + url, + jsonLd: { + '@context': 'https://schema.org', + '@type': 'Blog', + name: blog?.seo?.title || blog?.title || '', + description: blog?.seo?.description || '', + url, + }, + }; +} + +function page({ + page, + url, +}: { + page: Page; + url: Request['url']; +}): SeoConfig { + return { + description: truncate(page?.seo?.description || ''), + title: page?.seo?.title, + titleTemplate: '%s | Page', + url, + jsonLd: { + '@context': 'https://schema.org', + '@type': 'WebPage', + name: page.title, + }, + }; +} + +function policy({ + policy, + url, +}: { + policy: ShopPolicy; + url: Request['url']; +}): SeoConfig { + return { + description: truncate(policy?.body ?? ''), + title: policy?.title, + titleTemplate: '%s | Policy', + url, + }; +} + +function policies({ + policies, + url, +}: { + policies: ShopPolicy[]; + url: Request['url']; +}): SeoConfig { + const origin = new URL(url).origin; + const itemListElement: BreadcrumbList['itemListElement'] = policies + .filter(Boolean) + .map((policy, index) => { + return { + '@type': 'ListItem', + position: index + 1, + name: policy.title, + item: `${origin}/policies/${policy.handle}`, + }; + }); + return { + title: 'Policies', + titleTemplate: '%s | Policies', + description: 'Hydroge store policies', + jsonLd: [ + { + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement, + }, + { + '@context': 'https://schema.org', + '@type': 'WebPage', + description: 'Hydrogen store policies', + name: 'Policies', + url, + }, + ], + }; +} + +export const seoPayload = { + article, + blog, + collection, + home, + listCollections, + page, + policies, + policy, + product, + root, +}; + +/** + * Truncate a string to a given length, adding an ellipsis if it was truncated + * @param str - The string to truncate + * @param num - The maximum length of the string + * @returns The truncated string + * @example + * ```js + * truncate('Hello world', 5) // 'Hello...' + * ``` + */ +function truncate(str: string, num = 155): string { + if (typeof str !== 'string') return ''; + if (str.length <= num) { + return str; + } + return str.slice(0, num - 3) + '...'; +} diff --git a/templates/demo-store/app/root.tsx b/templates/demo-store/app/root.tsx index 99d9df9d5b..e364ecd205 100644 --- a/templates/demo-store/app/root.tsx +++ b/templates/demo-store/app/root.tsx @@ -15,39 +15,18 @@ import { useLoaderData, useMatches, } from '@remix-run/react'; -import { - ShopifySalesChannel, - Seo, - type SeoHandleFunction, -} from '@shopify/hydrogen'; +import {ShopifySalesChannel, Seo} from '@shopify/hydrogen'; import {Layout} from '~/components'; import {GenericError} from './components/GenericError'; import {NotFound} from './components/NotFound'; - import styles from './styles/app.css'; import favicon from '../public/favicon.svg'; - +import {seoPayload} from '~/lib/seo.server'; import {DEFAULT_LOCALE, parseMenu, type EnhancedMenu} from './lib/utils'; import invariant from 'tiny-invariant'; import {Shop, Cart} from '@shopify/hydrogen/storefront-api-types'; import {useAnalytics} from './hooks/useAnalytics'; -const seo: SeoHandleFunction = ({data, pathname}) => ({ - title: data?.layout?.shop?.name, - titleTemplate: '%s | Hydrogen Demo Store', - description: data?.layout?.shop?.description, - handle: '@shopify', - url: `https://hydrogen.shop${pathname}`, - robots: { - noIndex: false, - noFollow: false, - }, -}); - -export const handle = { - seo, -}; - export const links: LinksFunction = () => { return [ {rel: 'stylesheet', href: styles}, @@ -68,12 +47,14 @@ export const meta: MetaFunction = () => ({ viewport: 'width=device-width,initial-scale=1', }); -export async function loader({context}: LoaderArgs) { +export async function loader({request, context}: LoaderArgs) { const [cartId, layout] = await Promise.all([ context.session.get('cartId'), getLayoutData(context), ]); + const seo = seoPayload.root({shop: layout.shop, url: request.url}); + return defer({ layout, selectedLocale: context.storefront.i18n, @@ -82,6 +63,7 @@ export async function loader({context}: LoaderArgs) { shopifySalesChannel: ShopifySalesChannel.hydrogen, shopId: layout.shop.id, }, + seo, }); } @@ -176,6 +158,16 @@ const LAYOUT_QUERY = `#graphql id name description + primaryDomain { + url + } + brand { + logo { + image { + url + } + } + } } headerMenu: menu(handle: $headerMenuHandle) { id diff --git a/templates/demo-store/app/routes/($lang)/collections/$collectionHandle.tsx b/templates/demo-store/app/routes/($lang)/collections/$collectionHandle.tsx index ad965b194c..a8d531349d 100644 --- a/templates/demo-store/app/routes/($lang)/collections/$collectionHandle.tsx +++ b/templates/demo-store/app/routes/($lang)/collections/$collectionHandle.tsx @@ -5,33 +5,13 @@ import type { CollectionConnection, Filter, } from '@shopify/hydrogen/storefront-api-types'; -import { - flattenConnection, - AnalyticsPageType, - type SeoHandleFunction, -} from '@shopify/hydrogen'; +import {flattenConnection, AnalyticsPageType} from '@shopify/hydrogen'; import invariant from 'tiny-invariant'; import {PageHeader, Section, Text, SortFilter} from '~/components'; import {ProductGrid} from '~/components/ProductGrid'; import {PRODUCT_CARD_FRAGMENT} from '~/data/fragments'; import {CACHE_SHORT, routeHeaders} from '~/data/cache'; - -const seo: SeoHandleFunction = ({data}) => ({ - title: data?.collection?.seo?.title, - description: data?.collection?.seo?.description, - titleTemplate: '%s | Collection', - media: { - type: 'image', - url: data?.collection?.image?.url, - height: data?.collection?.image?.height, - width: data?.collection?.image?.width, - altText: data?.collection?.image?.altText, - }, -}); - -export const handle = { - seo, -}; +import {seoPayload} from '~/lib/seo.server'; export const headers = routeHeaders; @@ -144,6 +124,7 @@ export async function loader({params, request, context}: LoaderArgs) { } const collectionNodes = flattenConnection(collections); + const seo = seoPayload.collection({collection, url: request.url}); return json( { @@ -155,6 +136,7 @@ export async function loader({params, request, context}: LoaderArgs) { collectionHandle, resourceId: collection.id, }, + seo, }, { headers: { diff --git a/templates/demo-store/app/routes/($lang)/collections/index.tsx b/templates/demo-store/app/routes/($lang)/collections/index.tsx index 2f5a660d41..5e5df08362 100644 --- a/templates/demo-store/app/routes/($lang)/collections/index.tsx +++ b/templates/demo-store/app/routes/($lang)/collections/index.tsx @@ -1,4 +1,4 @@ -import {json, type MetaFunction, type LoaderArgs} from '@shopify/remix-oxygen'; +import {json, type LoaderArgs} from '@shopify/remix-oxygen'; import {useLoaderData} from '@remix-run/react'; import type { Collection, @@ -15,16 +15,11 @@ import { Button, } from '~/components'; import {getImageLoadingPriority} from '~/lib/const'; +import {seoPayload} from '~/lib/seo.server'; import {CACHE_SHORT, routeHeaders} from '~/data/cache'; const PAGINATION_SIZE = 8; -export const handle = { - seo: { - title: 'All Collections', - }, -}; - export const headers = routeHeaders; export const loader = async ({request, context: {storefront}}: LoaderArgs) => { @@ -39,8 +34,13 @@ export const loader = async ({request, context: {storefront}}: LoaderArgs) => { }, }); + const seo = seoPayload.listCollections({ + collections, + url: request.url, + }); + return json( - {collections}, + {collections, seo}, { headers: { 'Cache-Control': CACHE_SHORT, @@ -49,12 +49,6 @@ export const loader = async ({request, context: {storefront}}: LoaderArgs) => { ); }; -export const meta: MetaFunction = () => { - return { - title: 'All Collections', - }; -}; - export default function Collections() { const {collections} = useLoaderData(); diff --git a/templates/demo-store/app/routes/($lang)/index.tsx b/templates/demo-store/app/routes/($lang)/index.tsx index 6be7af549a..69d43db564 100644 --- a/templates/demo-store/app/routes/($lang)/index.tsx +++ b/templates/demo-store/app/routes/($lang)/index.tsx @@ -4,6 +4,7 @@ import {Await, useLoaderData} from '@remix-run/react'; import {ProductSwimlane, FeaturedCollections, Hero} from '~/components'; import {MEDIA_FRAGMENT, PRODUCT_CARD_FRAGMENT} from '~/data/fragments'; import {getHeroPlaceholder} from '~/lib/placeholders'; +import {seoPayload} from '~/lib/seo.server'; import type { CollectionConnection, Metafield, @@ -52,6 +53,8 @@ export async function loader({params, context}: LoaderArgs) { variables: {handle: 'freestyle'}, }); + const seo = seoPayload.home(); + return defer( { shop, @@ -102,6 +105,7 @@ export async function loader({params, context}: LoaderArgs) { analytics: { pageType: AnalyticsPageType.home, }, + seo, }, { headers: { @@ -123,13 +127,6 @@ export default function Homepage() { // TODO: skeletons vs placeholders const skeletons = getHeroPlaceholder([{}, {}, {}]); - // TODO: analytics - // useServerAnalytics({ - // shopify: { - // pageType: ShopifyAnalyticsConstants.pageType.home, - // }, - // }); - return ( <> {primaryHero && ( diff --git a/templates/demo-store/app/routes/($lang)/journal/$journalHandle.tsx b/templates/demo-store/app/routes/($lang)/journal/$journalHandle.tsx index 6b14338a59..cba408f2e6 100644 --- a/templates/demo-store/app/routes/($lang)/journal/$journalHandle.tsx +++ b/templates/demo-store/app/routes/($lang)/journal/$journalHandle.tsx @@ -1,35 +1,23 @@ -import { - json, - type MetaFunction, - type SerializeFrom, - type LinksFunction, - type LoaderArgs, -} from '@shopify/remix-oxygen'; +import {json, type LinksFunction, type LoaderArgs} from '@shopify/remix-oxygen'; import {useLoaderData} from '@remix-run/react'; import {Image} from '@shopify/hydrogen'; import {Blog} from '@shopify/hydrogen/storefront-api-types'; import invariant from 'tiny-invariant'; import {PageHeader, Section} from '~/components'; import {ATTR_LOADING_EAGER} from '~/lib/const'; +import {seoPayload} from '~/lib/seo.server'; import styles from '../../../styles/custom-font.css'; -import type {SeoHandleFunction} from '@shopify/hydrogen'; import {routeHeaders, CACHE_LONG} from '~/data/cache'; const BLOG_HANDLE = 'journal'; -const seo: SeoHandleFunction = ({data}) => ({ - title: data?.article?.seo?.title, - description: data?.article?.seo?.description, - titleTemplate: '%s | Journal', -}); +export const headers = routeHeaders; -export const handle = { - seo, +export const links: LinksFunction = () => { + return [{rel: 'stylesheet', href: styles}]; }; -export const headers = routeHeaders; - -export async function loader({params, context}: LoaderArgs) { +export async function loader({request, params, context}: LoaderArgs) { const {language, country} = context.storefront.i18n; invariant(params.journalHandle, 'Missing journal handle'); @@ -56,8 +44,10 @@ export async function loader({params, context}: LoaderArgs) { day: 'numeric', }).format(new Date(article?.publishedAt!)); + const seo = seoPayload.article({article, url: request.url}); + return json( - {article, formattedDate}, + {article, formattedDate, seo}, { headers: { 'Cache-Control': CACHE_LONG, @@ -66,21 +56,6 @@ export async function loader({params, context}: LoaderArgs) { ); } -export const meta: MetaFunction = ({ - data, -}: { - data: SerializeFrom | undefined; -}) => { - return { - title: data?.article?.seo?.title ?? 'Article', - description: data?.article?.seo?.description, - }; -}; - -export const links: LinksFunction = () => { - return [{rel: 'stylesheet', href: styles}]; -}; - export default function Article() { const {article, formattedDate} = useLoaderData(); diff --git a/templates/demo-store/app/routes/($lang)/journal/index.tsx b/templates/demo-store/app/routes/($lang)/journal/index.tsx index 26b168fab7..545b5a5e76 100644 --- a/templates/demo-store/app/routes/($lang)/journal/index.tsx +++ b/templates/demo-store/app/routes/($lang)/journal/index.tsx @@ -1,22 +1,17 @@ -import {json, type MetaFunction, type LoaderArgs} from '@shopify/remix-oxygen'; +import {json, type LoaderArgs} from '@shopify/remix-oxygen'; import {useLoaderData} from '@remix-run/react'; import {flattenConnection, Image} from '@shopify/hydrogen'; import type {Article, Blog} from '@shopify/hydrogen/storefront-api-types'; import {Grid, PageHeader, Section, Link} from '~/components'; import {getImageLoadingPriority, PAGINATION_SIZE} from '~/lib/const'; +import {seoPayload} from '~/lib/seo.server'; import {CACHE_SHORT, routeHeaders} from '~/data/cache'; const BLOG_HANDLE = 'Journal'; -export const handle = { - seo: { - title: 'Journal', - }, -}; - export const headers = routeHeaders; -export const loader = async ({context: {storefront}}: LoaderArgs) => { +export const loader = async ({request, context: {storefront}}: LoaderArgs) => { const {language, country} = storefront.i18n; const {blog} = await storefront.query<{ blog: Blog; @@ -44,8 +39,10 @@ export const loader = async ({context: {storefront}}: LoaderArgs) => { }; }); + const seo = seoPayload.blog({blog, url: request.url}); + return json( - {articles}, + {articles, seo}, { headers: { 'Cache-Control': CACHE_SHORT, @@ -54,12 +51,6 @@ export const loader = async ({context: {storefront}}: LoaderArgs) => { ); }; -export const meta: MetaFunction = () => { - return { - title: 'All Journals', - }; -}; - export default function Journals() { const {articles} = useLoaderData(); @@ -126,6 +117,11 @@ query Blog( $cursor: String ) @inContext(language: $language) { blog(handle: $blogHandle) { + title + seo { + title + description + } articles(first: $pageBy, after: $cursor) { edges { node { diff --git a/templates/demo-store/app/routes/($lang)/pages/$pageHandle.tsx b/templates/demo-store/app/routes/($lang)/pages/$pageHandle.tsx index 9de1d99486..3edc79d1b5 100644 --- a/templates/demo-store/app/routes/($lang)/pages/$pageHandle.tsx +++ b/templates/demo-store/app/routes/($lang)/pages/$pageHandle.tsx @@ -1,27 +1,13 @@ -import { - json, - type MetaFunction, - type SerializeFrom, - type LoaderArgs, -} from '@shopify/remix-oxygen'; +import {json, type LoaderArgs} from '@shopify/remix-oxygen'; import type {Page as PageType} from '@shopify/hydrogen/storefront-api-types'; import {useLoaderData} from '@remix-run/react'; import invariant from 'tiny-invariant'; import {PageHeader} from '~/components'; -import type {SeoHandleFunction} from '@shopify/hydrogen'; import {CACHE_LONG, routeHeaders} from '~/data/cache'; - -const seo: SeoHandleFunction = ({data}) => ({ - title: data?.page?.seo?.title, - description: data?.page?.seo?.description, -}); +import {seoPayload} from '~/lib/seo.server'; export const headers = routeHeaders; -export const handle = { - seo, -}; - export async function loader({request, params, context}: LoaderArgs) { invariant(params.pageHandle, 'Missing page handle'); @@ -36,8 +22,10 @@ export async function loader({request, params, context}: LoaderArgs) { throw new Response(null, {status: 404}); } + const seo = seoPayload.page({page, url: request.url}); + return json( - {page}, + {page, seo}, { headers: { 'Cache-Control': CACHE_LONG, diff --git a/templates/demo-store/app/routes/($lang)/policies/$policyHandle.tsx b/templates/demo-store/app/routes/($lang)/policies/$policyHandle.tsx index 9b37c87fb3..5e7e796732 100644 --- a/templates/demo-store/app/routes/($lang)/policies/$policyHandle.tsx +++ b/templates/demo-store/app/routes/($lang)/policies/$policyHandle.tsx @@ -1,10 +1,10 @@ import {json, type MetaFunction, type LoaderArgs} from '@shopify/remix-oxygen'; import {useLoaderData} from '@remix-run/react'; - import {PageHeader, Section, Button} from '~/components'; import invariant from 'tiny-invariant'; import {ShopPolicy} from '@shopify/hydrogen/storefront-api-types'; import {routeHeaders, CACHE_LONG} from '~/data/cache'; +import {seoPayload} from '~/lib/seo.server'; export const headers = routeHeaders; @@ -36,8 +36,10 @@ export async function loader({request, params, context}: LoaderArgs) { throw new Response(null, {status: 404}); } + const seo = seoPayload.policy({policy, url: request.url}); + return json( - {policy}, + {policy, seo}, { headers: { 'Cache-Control': CACHE_LONG, @@ -46,12 +48,6 @@ export async function loader({request, params, context}: LoaderArgs) { ); } -export const meta: MetaFunction = ({data}) => { - return { - title: data?.policy?.title ?? 'Policies', - }; -}; - export default function Policies() { const {policy} = useLoaderData(); diff --git a/templates/demo-store/app/routes/($lang)/policies/index.tsx b/templates/demo-store/app/routes/($lang)/policies/index.tsx index 01a4c9cac5..f8afdb0ac3 100644 --- a/templates/demo-store/app/routes/($lang)/policies/index.tsx +++ b/templates/demo-store/app/routes/($lang)/policies/index.tsx @@ -2,19 +2,13 @@ import {json, type LoaderArgs} from '@shopify/remix-oxygen'; import {useLoaderData} from '@remix-run/react'; import type {ShopPolicy} from '@shopify/hydrogen/storefront-api-types'; import invariant from 'tiny-invariant'; - import {PageHeader, Section, Heading, Link} from '~/components'; import {routeHeaders, CACHE_LONG} from '~/data/cache'; - -export const handle = { - seo: { - title: 'Policies', - }, -}; +import {seoPayload} from '~/lib/seo.server'; export const headers = routeHeaders; -export async function loader({context: {storefront}}: LoaderArgs) { +export async function loader({request, context: {storefront}}: LoaderArgs) { const data = await storefront.query<{ shop: Record; }>(POLICIES_QUERY); @@ -26,9 +20,12 @@ export async function loader({context: {storefront}}: LoaderArgs) { throw new Response('Not found', {status: 404}); } + const seo = seoPayload.policies({policies, url: request.url}); + return json( { policies, + seo, }, { headers: { diff --git a/templates/demo-store/app/routes/($lang)/products/$productHandle.tsx b/templates/demo-store/app/routes/($lang)/products/$productHandle.tsx index 277d02d1df..9b2e5aa5f0 100644 --- a/templates/demo-store/app/routes/($lang)/products/$productHandle.tsx +++ b/templates/demo-store/app/routes/($lang)/products/$productHandle.tsx @@ -13,9 +13,6 @@ import { Money, ShopifyAnalyticsProduct, ShopPayButton, - flattenConnection, - type SeoHandleFunction, - type SeoConfig, } from '@shopify/hydrogen'; import { Heading, @@ -32,6 +29,7 @@ import { Button, } from '~/components'; import {getExcerpt} from '~/lib/utils'; +import {seoPayload} from '~/lib/seo.server'; import invariant from 'tiny-invariant'; import clsx from 'clsx'; import type { @@ -40,8 +38,6 @@ import type { Product as ProductType, Shop, ProductConnection, - MediaConnection, - MediaImage, } from '@shopify/hydrogen/storefront-api-types'; import {MEDIA_FRAGMENT, PRODUCT_CARD_FRAGMENT} from '~/data/fragments'; import type {Storefront} from '~/lib/type'; @@ -90,34 +86,24 @@ export async function loader({params, request, context}: LoaderArgs) { price: selectedVariant.price.amount, }; - const media = flattenConnection(product.media).find( - (media) => media.mediaContentType === 'IMAGE', - ) as MediaImage | undefined; - - const seo = { - title: product?.seo?.title ?? product?.title, - media: media?.image, - description: product?.seo?.description ?? product?.description, - jsonLd: { - '@context': 'https://schema.org', - '@type': 'Product', - brand: product?.vendor, - name: product?.title, - }, - } satisfies SeoConfig; + const seo = seoPayload.product({ + product, + selectedVariant, + url: request.url, + }); return defer( { product, shop, recommended, - seo, analytics: { pageType: AnalyticsPageType.product, resourceId: product.id, products: [productAnalytics], totalValue: parseFloat(selectedVariant.price.amount), }, + seo, }, { headers: { diff --git a/templates/demo-store/app/routes/($lang)/products/index.tsx b/templates/demo-store/app/routes/($lang)/products/index.tsx index b8096e8815..9bac2b7b2e 100644 --- a/templates/demo-store/app/routes/($lang)/products/index.tsx +++ b/templates/demo-store/app/routes/($lang)/products/index.tsx @@ -1,6 +1,9 @@ import {json, type LoaderArgs} from '@shopify/remix-oxygen'; import {useLoaderData} from '@remix-run/react'; -import type {ProductConnection} from '@shopify/hydrogen/storefront-api-types'; +import type { + ProductConnection, + Collection, +} from '@shopify/hydrogen/storefront-api-types'; import invariant from 'tiny-invariant'; import { PageHeader, @@ -13,6 +16,7 @@ import { } from '~/components'; import {PRODUCT_CARD_FRAGMENT} from '~/data/fragments'; import {getImageLoadingPriority} from '~/lib/const'; +import {seoPayload} from '~/lib/seo.server'; import {routeHeaders, CACHE_SHORT} from '~/data/cache'; const PAGE_BY = 8; @@ -34,20 +38,41 @@ export async function loader({request, context: {storefront}}: LoaderArgs) { invariant(data, 'No data returned from Shopify API'); - return json(data.products, { - headers: { - 'Cache-Control': CACHE_SHORT, + const seoCollection = { + id: 'all-products', + title: 'All Products', + handle: 'products', + descriptionHtml: 'All the store products', + description: 'All the store products', + seo: { + title: 'All Products', + description: 'All the store products', }, + metafields: [], + products: data.products, + updatedAt: '', + } satisfies Collection; + + const seo = seoPayload.collection({ + collection: seoCollection, + url: request.url, }); + + return json( + { + products: data.products, + seo, + }, + { + headers: { + 'Cache-Control': CACHE_SHORT, + }, + }, + ); } -export const handle = { - seo: { - title: 'Products', - }, -}; export default function AllProducts() { - const products = useLoaderData(); + const {products} = useLoaderData(); return ( <> diff --git a/templates/demo-store/app/routes/($lang)/search.tsx b/templates/demo-store/app/routes/($lang)/search.tsx index d77f102206..4e2fd702ca 100644 --- a/templates/demo-store/app/routes/($lang)/search.tsx +++ b/templates/demo-store/app/routes/($lang)/search.tsx @@ -1,4 +1,8 @@ -import {defer, type LoaderArgs} from '@shopify/remix-oxygen'; +import { + defer, + type LoaderArgs, + type SerializeFrom, +} from '@shopify/remix-oxygen'; import {flattenConnection} from '@shopify/hydrogen'; import {Await, Form, useLoaderData} from '@remix-run/react'; import type { @@ -21,8 +25,60 @@ import { } from '~/components'; import {PRODUCT_CARD_FRAGMENT} from '~/data/fragments'; import {PAGINATION_SIZE} from '~/lib/const'; +import {seoPayload} from '~/lib/seo.server'; + +export async function loader({request, context: {storefront}}: LoaderArgs) { + const searchParams = new URL(request.url).searchParams; + const cursor = searchParams.get('cursor')!; + const searchTerm = searchParams.get('q')!; + + const data = await storefront.query<{ + products: ProductConnection; + }>(SEARCH_QUERY, { + variables: { + pageBy: PAGINATION_SIZE, + searchTerm, + cursor, + country: storefront.i18n.country, + language: storefront.i18n.language, + }, + }); -export default function () { + invariant(data, 'No data returned from Shopify API'); + const {products} = data; + + const getRecommendations = !searchTerm || products?.nodes?.length === 0; + const seoCollection = { + id: 'search', + title: 'Search', + handle: 'search', + descriptionHtml: 'Search results', + description: 'Search results', + seo: { + title: 'Search', + description: `Showing ${products.nodes.length} search results for "${searchTerm}"`, + }, + metafields: [], + products, + updatedAt: new Date().toISOString(), + } satisfies Collection; + + const seo = seoPayload.collection({ + collection: seoCollection, + url: request.url, + }); + + return defer({ + seo, + searchTerm, + products, + noResultRecommendations: getRecommendations + ? getNoResultRecommendations(storefront) + : Promise.resolve(null), + }); +} + +export default function Search() { const {searchTerm, products, noResultRecommendations} = useLoaderData(); const noResults = products?.nodes?.length === 0; @@ -64,11 +120,15 @@ export default function () { <> } + collections={ + data!.featuredCollections as SerializeFrom + } /> } + products={ + data!.featuredProducts as SerializeFrom + } /> )} @@ -88,37 +148,6 @@ export default function () { ); } -export async function loader({request, context: {storefront}}: LoaderArgs) { - const searchParams = new URL(request.url).searchParams; - const cursor = searchParams.get('cursor')!; - const searchTerm = searchParams.get('q')!; - - const data = await storefront.query<{ - products: ProductConnection; - }>(SEARCH_QUERY, { - variables: { - pageBy: PAGINATION_SIZE, - searchTerm, - cursor, - country: storefront.i18n.country, - language: storefront.i18n.language, - }, - }); - - invariant(data, 'No data returned from Shopify API'); - const {products} = data; - - const getRecommendations = !searchTerm || products?.nodes?.length === 0; - - return defer({ - searchTerm, - products, - noResultRecommendations: getRecommendations - ? getNoResultRecommendations(storefront) - : Promise.resolve(null), - }); -} - const SEARCH_QUERY = `#graphql ${PRODUCT_CARD_FRAGMENT} query search(