From eccdd8bbe9cc91d02dd0d9e519235d810cc94c6c Mon Sep 17 00:00:00 2001 From: decobot Date: Wed, 1 Jul 2026 16:54:17 -0300 Subject: [PATCH 1/9] feat(blog): add ProductShelf and Table blocks Add two native blogpost content blocks so editors no longer need manual HTML: - ProductShelf: renders a resolved product list (title + Product[] from a productList loader ref) as a responsive grid of cards with image, name, price and link. - Table: renders header/body rows (tolerant to native arrays or JSON strings), sanitizing cell HTML for inline formatting. Wire the Table into the Spire path (blocksToSections.ts) and register both blocks in the generated manifest. ProductShelf is loader-backed, so it is composed exclusively via the studio editor. Highlight (DECO-5397) is dropped: already covered by the existing Callout block. Refs DECO-5395, DECO-5396 Co-Authored-By: Claude Opus 4.8 --- blog/manifest.gen.ts | 4 ++ blog/sections/blocks/ProductShelf.tsx | 99 +++++++++++++++++++++++++++ blog/sections/blocks/Table.tsx | 76 ++++++++++++++++++++ blog/utils/blocksToSections.ts | 6 ++ 4 files changed, 185 insertions(+) create mode 100644 blog/sections/blocks/ProductShelf.tsx create mode 100644 blog/sections/blocks/Table.tsx diff --git a/blog/manifest.gen.ts b/blog/manifest.gen.ts index 8ccb0c8ce..22517c3ad 100644 --- a/blog/manifest.gen.ts +++ b/blog/manifest.gen.ts @@ -34,10 +34,12 @@ import * as $$$$$$10 from "./sections/blocks/Divider.tsx"; import * as $$$$$$11 from "./sections/blocks/Heading.tsx"; import * as $$$$$$12 from "./sections/blocks/List.tsx"; import * as $$$$$$13 from "./sections/blocks/Paragraph.tsx"; +import * as $$$$$$19 from "./sections/blocks/ProductShelf.tsx"; import * as $$$$$$14 from "./sections/blocks/Quote.tsx"; import * as $$$$$$15 from "./sections/blocks/Stat.tsx"; import * as $$$$$$16 from "./sections/blocks/StatGroup.tsx"; import * as $$$$$$17 from "./sections/blocks/Steps.tsx"; +import * as $$$$$$20 from "./sections/blocks/Table.tsx"; import * as $$$$$$18 from "./sections/blocks/Video.tsx"; import * as $$$$$$0 from "./sections/Seo/SeoBlogPost.tsx"; import * as $$$$$$1 from "./sections/Seo/SeoBlogPostListing.tsx"; @@ -76,10 +78,12 @@ const manifest = { "blog/sections/blocks/Heading.tsx": $$$$$$11, "blog/sections/blocks/List.tsx": $$$$$$12, "blog/sections/blocks/Paragraph.tsx": $$$$$$13, + "blog/sections/blocks/ProductShelf.tsx": $$$$$$19, "blog/sections/blocks/Quote.tsx": $$$$$$14, "blog/sections/blocks/Stat.tsx": $$$$$$15, "blog/sections/blocks/StatGroup.tsx": $$$$$$16, "blog/sections/blocks/Steps.tsx": $$$$$$17, + "blog/sections/blocks/Table.tsx": $$$$$$20, "blog/sections/blocks/Video.tsx": $$$$$$18, "blog/sections/Seo/SeoBlogPost.tsx": $$$$$$0, "blog/sections/Seo/SeoBlogPostListing.tsx": $$$$$$1, diff --git a/blog/sections/blocks/ProductShelf.tsx b/blog/sections/blocks/ProductShelf.tsx new file mode 100644 index 000000000..7ec411842 --- /dev/null +++ b/blog/sections/blocks/ProductShelf.tsx @@ -0,0 +1,99 @@ +import type { Product } from "../../../commerce/types.ts"; + +export interface Props { + title?: string; + /** Resolved by the referenced productList loader (e.g. vtex/loaders/intelligentSearch/productList.ts) */ + products?: Product[] | null; +} + +const GRID_CLASS: Record = { + 1: "grid-cols-1", + 2: "grid-cols-2", + 3: "grid-cols-2 sm:grid-cols-3", + 4: "grid-cols-2 sm:grid-cols-4", +}; + +function formatPrice(value: number, currency?: string) { + try { + return new Intl.NumberFormat("pt-BR", { + style: currency ? "currency" : "decimal", + currency: currency || undefined, + }).format(value); + } catch { + return value.toFixed(2); + } +} + +export default function ProductShelf({ title, products }: Props) { + const items = Array.isArray(products) ? products : []; + if (items.length === 0) return null; + + const cols = Math.min(items.length, 4); + const gridCols = GRID_CLASS[cols] ?? GRID_CLASS[4]; + + return ( +
+ {title && ( +

+ {title} +

+ )} +
+ {items.map((product, i) => { + const name = product.isVariantOf?.name ?? product.name ?? ""; + const image = product.image?.[0]?.url; + const offer = product.offers; + const price = offer?.offers?.[0]?.price ?? offer?.lowPrice; + const currency = offer?.priceCurrency; + + const card = ( + <> +
+ {image + ? ( + {product.image?.[0]?.alternateName + ) + : ( +
+ — +
+ )} +
+
+ + {name} + + {typeof price === "number" && ( + + {formatPrice(price, currency)} + + )} +
+ + ); + + return product.url + ? ( + + {card} + + ) + : ( +
+ {card} +
+ ); + })} +
+
+ ); +} diff --git a/blog/sections/blocks/Table.tsx b/blog/sections/blocks/Table.tsx new file mode 100644 index 000000000..a8cc920ff --- /dev/null +++ b/blog/sections/blocks/Table.tsx @@ -0,0 +1,76 @@ +import { sanitizeHtml } from "../../utils/sanitizeHtml.ts"; + +export interface Props { + /** JSON-encoded string[] — optional header row */ + headers?: string[] | string; + /** JSON-encoded string[][] — body rows × cells */ + rows?: string[][] | string; +} + +function parseHeaders(value: string[] | string | undefined): string[] { + if (Array.isArray(value)) return value.map((c) => String(c ?? "")); + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) return parsed.map((c) => String(c ?? "")); + } catch { /* ignore */ } + } + return []; +} + +function parseRows(value: string[][] | string | undefined): string[][] { + const toRow = (row: unknown): string[] => + Array.isArray(row) ? row.map((c) => String(c ?? "")) : []; + + if (Array.isArray(value)) return value.map(toRow); + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) return parsed.map(toRow); + } catch { /* ignore */ } + } + return []; +} + +export default function Table({ headers, rows }: Props) { + const head = parseHeaders(headers); + const body = parseRows(rows); + + if (head.length === 0 && body.length === 0) return null; + + const cellClass = + "px-4 py-3 text-sm leading-normal align-top [&_a]:text-accent [&_a]:underline [&_strong]:font-semibold [&_strong]:text-base"; + + return ( +
+ + {head.length > 0 && ( + + + {head.map((cell, i) => ( + + + )} + + {body.map((row, r) => ( + + {row.map((cell, c) => ( + + ))} + +
+ ))} +
+ ))} +
+
+ ); +} diff --git a/blog/utils/blocksToSections.ts b/blog/utils/blocksToSections.ts index 8aef79ac5..54f1c78c0 100644 --- a/blog/utils/blocksToSections.ts +++ b/blog/utils/blocksToSections.ts @@ -120,6 +120,12 @@ function blockToSection( right: content.right, }); + case "table": + return toSection(`${BASE}/Table.tsx`, { + headers: content.headers, + rows: content.rows, + }); + case "image": return toSection(`${BASE}/BlockImage.tsx`, { url: content.url, From 3e6352eaec2eb368e75170b11776b7faeeb97875 Mon Sep 17 00:00:00 2001 From: decobot Date: Wed, 1 Jul 2026 17:00:52 -0300 Subject: [PATCH 2/9] feat(blog): map product-shelf in blocksToSections Add a product-shelf case to the Spire block converter so a vitrine block in the content vocabulary is mapped to ProductShelf.tsx instead of being silently dropped by the default branch. Mirrors the other cases: it forwards title and products (a productList loader ref resolved by deco, or an already resolved Product[]). Co-Authored-By: Claude Opus 4.8 --- blog/utils/blocksToSections.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/blog/utils/blocksToSections.ts b/blog/utils/blocksToSections.ts index 54f1c78c0..24bc546dc 100644 --- a/blog/utils/blocksToSections.ts +++ b/blog/utils/blocksToSections.ts @@ -126,6 +126,13 @@ function blockToSection( rows: content.rows, }); + case "product-shelf": + return toSection(`${BASE}/ProductShelf.tsx`, { + title: content.title, + // May be a productList loader ref (resolved by deco) or resolved Product[] + products: content.products, + }); + case "image": return toSection(`${BASE}/BlockImage.tsx`, { url: content.url, From 411ba50f37682c3f698d880d345ec0af2fc64a48 Mon Sep 17 00:00:00 2001 From: decobot Date: Wed, 1 Jul 2026 18:43:28 -0300 Subject: [PATCH 3/9] feat(blog): drop ProductShelf block, keep Table Remove the ProductShelf block (component, Spire converter case and manifest entry). Scope narrowed to the Table block only. Co-Authored-By: Claude Opus 4.8 --- blog/manifest.gen.ts | 2 - blog/sections/blocks/ProductShelf.tsx | 99 --------------------------- blog/utils/blocksToSections.ts | 7 -- 3 files changed, 108 deletions(-) delete mode 100644 blog/sections/blocks/ProductShelf.tsx diff --git a/blog/manifest.gen.ts b/blog/manifest.gen.ts index 22517c3ad..dddc9d6f3 100644 --- a/blog/manifest.gen.ts +++ b/blog/manifest.gen.ts @@ -34,7 +34,6 @@ import * as $$$$$$10 from "./sections/blocks/Divider.tsx"; import * as $$$$$$11 from "./sections/blocks/Heading.tsx"; import * as $$$$$$12 from "./sections/blocks/List.tsx"; import * as $$$$$$13 from "./sections/blocks/Paragraph.tsx"; -import * as $$$$$$19 from "./sections/blocks/ProductShelf.tsx"; import * as $$$$$$14 from "./sections/blocks/Quote.tsx"; import * as $$$$$$15 from "./sections/blocks/Stat.tsx"; import * as $$$$$$16 from "./sections/blocks/StatGroup.tsx"; @@ -78,7 +77,6 @@ const manifest = { "blog/sections/blocks/Heading.tsx": $$$$$$11, "blog/sections/blocks/List.tsx": $$$$$$12, "blog/sections/blocks/Paragraph.tsx": $$$$$$13, - "blog/sections/blocks/ProductShelf.tsx": $$$$$$19, "blog/sections/blocks/Quote.tsx": $$$$$$14, "blog/sections/blocks/Stat.tsx": $$$$$$15, "blog/sections/blocks/StatGroup.tsx": $$$$$$16, diff --git a/blog/sections/blocks/ProductShelf.tsx b/blog/sections/blocks/ProductShelf.tsx deleted file mode 100644 index 7ec411842..000000000 --- a/blog/sections/blocks/ProductShelf.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import type { Product } from "../../../commerce/types.ts"; - -export interface Props { - title?: string; - /** Resolved by the referenced productList loader (e.g. vtex/loaders/intelligentSearch/productList.ts) */ - products?: Product[] | null; -} - -const GRID_CLASS: Record = { - 1: "grid-cols-1", - 2: "grid-cols-2", - 3: "grid-cols-2 sm:grid-cols-3", - 4: "grid-cols-2 sm:grid-cols-4", -}; - -function formatPrice(value: number, currency?: string) { - try { - return new Intl.NumberFormat("pt-BR", { - style: currency ? "currency" : "decimal", - currency: currency || undefined, - }).format(value); - } catch { - return value.toFixed(2); - } -} - -export default function ProductShelf({ title, products }: Props) { - const items = Array.isArray(products) ? products : []; - if (items.length === 0) return null; - - const cols = Math.min(items.length, 4); - const gridCols = GRID_CLASS[cols] ?? GRID_CLASS[4]; - - return ( -
- {title && ( -

- {title} -

- )} -
- {items.map((product, i) => { - const name = product.isVariantOf?.name ?? product.name ?? ""; - const image = product.image?.[0]?.url; - const offer = product.offers; - const price = offer?.offers?.[0]?.price ?? offer?.lowPrice; - const currency = offer?.priceCurrency; - - const card = ( - <> -
- {image - ? ( - {product.image?.[0]?.alternateName - ) - : ( -
- — -
- )} -
-
- - {name} - - {typeof price === "number" && ( - - {formatPrice(price, currency)} - - )} -
- - ); - - return product.url - ? ( - - {card} - - ) - : ( -
- {card} -
- ); - })} -
-
- ); -} diff --git a/blog/utils/blocksToSections.ts b/blog/utils/blocksToSections.ts index 24bc546dc..54f1c78c0 100644 --- a/blog/utils/blocksToSections.ts +++ b/blog/utils/blocksToSections.ts @@ -126,13 +126,6 @@ function blockToSection( rows: content.rows, }); - case "product-shelf": - return toSection(`${BASE}/ProductShelf.tsx`, { - title: content.title, - // May be a productList loader ref (resolved by deco) or resolved Product[] - products: content.products, - }); - case "image": return toSection(`${BASE}/BlockImage.tsx`, { url: content.url, From 23747fd9dc6a9250a95e7f589f37f77b8eec1404 Mon Sep 17 00:00:00 2001 From: decobot Date: Fri, 3 Jul 2026 10:51:14 -0300 Subject: [PATCH 4/9] fix(blog): harden sanitizeHtml denylist against more XSS vectors The regex sanitizer used by every blog block (including the new Table block) only stripped script/style/on*/javascript:/data:. Extend the shared, dependency-free sanitizer to also drop iframe, object, embed, applet, form, svg, math and other dangerous elements with their content, strip srcdoc and inline style attributes, and neutralize javascript:/data:/vbscript: protocols in more url-bearing attributes (incl. unquoted values). Kept as a hardened denylist rather than a parser-based sanitizer to preserve the util's no-dependency, SSR+browser contract and stay consistent across all blocks. Co-Authored-By: Claude Opus 4.8 --- blog/utils/sanitizeHtml.ts | 70 +++++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 8 deletions(-) diff --git a/blog/utils/sanitizeHtml.ts b/blog/utils/sanitizeHtml.ts index 4a67a8d06..cee4e984d 100644 --- a/blog/utils/sanitizeHtml.ts +++ b/blog/utils/sanitizeHtml.ts @@ -1,25 +1,79 @@ /** - * Lightweight allowlist-based HTML sanitizer that works in both Deno (SSR) + * Lightweight denylist-based HTML sanitizer that works in both Deno (SSR) * and browser contexts without external dependencies. * * Strips the most dangerous XSS vectors from CMS-provided HTML: - * -