Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
797 changes: 764 additions & 33 deletions cookbook/llms/markets.prompt.md

Large diffs are not rendered by default.

801 changes: 768 additions & 33 deletions cookbook/recipes/markets/README.md

Large diffs are not rendered by default.

525 changes: 525 additions & 0 deletions cookbook/recipes/markets/__tests__/sanitizeHtml.test.ts

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {useLoaderData} from 'react-router';
import type {Route} from './+types/($locale).blogs.$blogHandle.$articleHandle';
import {Image} from '@shopify/hydrogen';
import {redirectIfHandleIsLocalized} from '~/lib/redirect';
import {sanitizeHtml} from '~/lib/sanitizeHtml';

export const meta: Route.MetaFunction = ({data}) => {
return [{title: `Hydrogen | ${data?.article.title ?? ''} article`}];
Expand Down Expand Up @@ -51,7 +52,11 @@ async function loadCriticalData({context, request, params}: Route.LoaderArgs) {
},
);

const article = blog.articleByHandle;
const article = {
...blog.articleByHandle,
// Article content is merchant-managed HTML: sanitize before rendering it raw.
contentHtml: sanitizeHtml(blog.articleByHandle.contentHtml),
};

return {article};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import {
useLoaderData,
} from 'react-router';
import {useLoaderData} from 'react-router';
import type {Route} from './+types/($locale).pages.$handle';
import {redirectIfHandleIsLocalized} from '~/lib/redirect';
import {sanitizeHtml} from '~/lib/sanitizeHtml';

export const meta: Route.MetaFunction = ({data}) => {
return [{title: `Hydrogen | ${data?.page.title ?? ''}`}];
Expand All @@ -22,11 +21,7 @@ export async function loader(args: Route.LoaderArgs) {
* Load data necessary for rendering content above the fold. This is the critical data
* needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
*/
async function loadCriticalData({
context,
request,
params,
}: Route.LoaderArgs) {
async function loadCriticalData({context, request, params}: Route.LoaderArgs) {
if (!params.handle) {
throw new Error('Missing page handle');
}
Expand All @@ -47,7 +42,8 @@ async function loadCriticalData({
redirectIfHandleIsLocalized(request, {handle: params.handle, data: page});

return {
page,
// Page bodies are merchant-managed HTML: sanitize before rendering it raw.
page: {...page, body: sanitizeHtml(page.body)},
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import {
useLoaderData,
} from 'react-router';
import {useLoaderData} from 'react-router';
import {Link} from '~/components/Link';
import type {Route} from './+types/($locale).policies.$handle';
import {type Shop} from '@shopify/hydrogen/storefront-api-types';
import {sanitizeHtml} from '~/lib/sanitizeHtml';

type SelectedPolicies = keyof Pick<
Shop,
Expand Down Expand Up @@ -41,7 +40,8 @@ export async function loader({params, context}: Route.LoaderArgs) {
throw new Response('Could not find the policy', {status: 404});
}

return {policy};
// Policy bodies are merchant-managed HTML: sanitize before rendering it raw.
return {policy: {...policy, body: sanitizeHtml(policy.body)}};
}

export default function Policy() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {ProductForm} from '~/components/ProductForm';
import {redirectIfHandleIsLocalized} from '~/lib/redirect';
import type {WithLocale} from '~/lib/i18n';
import {DEFAULT_LOCALE} from '~/lib/i18n';
import {sanitizeHtml} from '~/lib/sanitizeHtml';

export const meta: Route.MetaFunction = (args) => {
const rootMatch = args.matches.at(0) ?? null;
Expand Down Expand Up @@ -48,11 +49,7 @@ export async function loader(args: Route.LoaderArgs) {
* Load data necessary for rendering content above the fold. This is the critical data
* needed to render the page. If it's unavailable, the whole page should 400 or 500 error.
*/
async function loadCriticalData({
context,
params,
request,
}: Route.LoaderArgs) {
async function loadCriticalData({context, params, request}: Route.LoaderArgs) {
const {handle} = params;
const {storefront} = context;

Expand Down Expand Up @@ -80,7 +77,11 @@ async function loadCriticalData({
redirectIfHandleIsLocalized(request, {handle, data: product});

return {
product,
// Product descriptions are merchant-managed HTML: sanitize before rendering it raw.
product: {
...product,
descriptionHtml: sanitizeHtml(product.descriptionHtml),
},
};
}

Expand Down
46 changes: 37 additions & 9 deletions cookbook/recipes/markets/recipe.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ ingredients:
description: Comprehensive i18n utilities including locale detection, path
transformation hooks, URL cleaning functions, and locale validation.
Centralizes all localization logic in one place.
- path: templates/skeleton/app/lib/sanitizeHtml.ts
description: An allowlist HTML sanitizer for merchant-managed rich text (page
bodies, article content, policies and product descriptions) before it is
rendered with dangerouslySetInnerHTML.
- path: templates/skeleton/app/routes/($locale)._index.tsx
description: A route that renders a localized version of the home page.
- path: templates/skeleton/app/routes/($locale).account.$.tsx
Expand Down Expand Up @@ -198,8 +202,25 @@ steps:
7. Support for case-insensitive locale matching
ingredients:
- path: templates/skeleton/app/lib/i18n.ts
- type: PATCH
- type: NEW_FILE
step: "1.6"
name: Create an HTML sanitizer for merchant-managed rich text
description: |
Pages, blog articles, policies and product descriptions come from the Storefront API
as HTML written by merchants, staff and apps, and the routes render that HTML with
`dangerouslySetInnerHTML`. Treat it as untrusted: create a `sanitizeHtml()` helper that
keeps formatting, tables, images and safe links, and removes anything that can run
script, such as `<script>` elements, event handler attributes and `javascript:` URLs.
It also removes embeds (`<iframe>`, `<video>`, `<audio>`), `style` and `id` attributes,
so YouTube embeds, inline styling and in-page anchor links won't render. Extend the
allowlist if your store relies on them.

Call it in each route's loader, so the sanitized HTML is what reaches the browser
in both the server render and client-side navigations.
ingredients:
- path: templates/skeleton/app/lib/sanitizeHtml.ts
- type: PATCH
step: "1.7"
name: Update ProductItem to use locale-aware Link
description: |
Replace standard react-router Link imports with the new unified Link component.
Expand All @@ -208,15 +229,15 @@ steps:
- file: app/components/ProductItem.tsx
patchFile: ProductItem.tsx.c6506f.patch
- type: PATCH
step: "1.7"
step: "1.8"
name: Add the selected locale to the context
description: |
Detect the locale from the URL path, and add it to the HydrogenContext.
diffs:
- file: app/lib/context.ts
patchFile: context.ts.c15041.patch
- type: PATCH
step: "1.8"
step: "1.9"
name: Update Header with CountrySelector and locale-aware Links
description: |
1. Add the `CountrySelector` component to the header navigation.
Expand All @@ -227,7 +248,7 @@ steps:
- file: app/components/Header.tsx
patchFile: Header.tsx.00776f.patch
- type: PATCH
step: "1.9"
step: "1.10"
name: Add the selected locale to the root route
description: |
1. Include the selected locale in the root route's loader data.
Expand Down Expand Up @@ -282,6 +303,7 @@ steps:
1. Add the dynamic segment to the product page route.
2. Update the `meta` function to also update the canonical URL to use the
localized prefix.
3. Sanitize the product's `descriptionHtml` with `sanitizeHtml()` in the loader.
ingredients:
- path: templates/skeleton/app/routes/($locale).products.$handle.tsx
renamedFrom: templates/skeleton/app/routes/products.$handle.tsx
Expand Down Expand Up @@ -358,7 +380,9 @@ steps:
- type: NEW_FILE
step: "2.17"
name: Show blog articles
description: Add a blog article page with locale-specific content and SEO metadata.
description: |
Add a blog article page with locale-specific content and SEO metadata.
Sanitize the article's `contentHtml` with `sanitizeHtml()` in the loader.
ingredients:
- path: templates/skeleton/app/routes/($locale).blogs.$blogHandle.$articleHandle.tsx
- type: NEW_FILE
Expand Down Expand Up @@ -395,14 +419,17 @@ steps:
- type: NEW_FILE
step: "2.23"
name: Display content pages
description: Add a dynamic page route for locale-specific content pages.
description: |
Add a dynamic page route for locale-specific content pages.
Sanitize the page `body` with `sanitizeHtml()` in the loader.
ingredients:
- path: templates/skeleton/app/routes/($locale).pages.$handle.tsx
- type: NEW_FILE
step: "2.24"
name: Show policy pages
description: Add a policy page (privacy, terms, etc.) with locale-specific legal
content.
description: |
Add a policy page (privacy, terms, etc.) with locale-specific legal content.
Sanitize the policy `body` with `sanitizeHtml()` in the loader.
ingredients:
- path: templates/skeleton/app/routes/($locale).policies.$handle.tsx
- type: NEW_FILE
Expand All @@ -421,7 +448,8 @@ steps:
- type: PATCH
step: "8"
name: package.json
description: null
description: Use published versions of the Hydrogen, React and tooling packages
in `package.json` instead of the monorepo's workspace references.
diffs:
- file: package.json
patchFile: package.json.8e0ff5.patch
Expand Down
Loading