Skip to content

feat: add spire blog app with overrideSections support#1561

Merged
guitavano merged 5 commits intomainfrom
feat/spire-override-sections
Apr 2, 2026
Merged

feat: add spire blog app with overrideSections support#1561
guitavano merged 5 commits intomainfrom
feat/spire-override-sections

Conversation

@guitavano
Copy link
Copy Markdown
Contributor

@guitavano guitavano commented Mar 24, 2026

Summary

  • Introduces the spire/ app integrating with the Spire Blog API
  • Adds an overrideSections prop (array of { key: BlockType, value: Section }) to the app config, letting users replace any default block renderer with a custom section via the deco admin UI
  • key is a typed union of all 19 block types (renders as a select in the admin), value is any deco Section

How it works

When a block type is matched in blocksToSections, the override map is checked first. If a match exists, the user-provided section is used instead of the default spire/sections/blocks/* component.

Test plan

  • Install the Spire app and confirm existing block rendering is unaffected with no overrides configured
  • Add an override (e.g. key: "product-shelf", value: custom section) and confirm the custom section renders in place of the default
  • Verify the key field renders as a dropdown in the deco admin UI

Summary by cubic

Adds the spire blog app with Spire API integration and safe block overrides via overrideSections. Adds loaders, default blocks, SEO, and caching; now sanitizes CMS HTML/URLs and fixes pagination/SEO edge cases.

  • New Features

    • Registers app("spire"); adds loaders: BlogPostPage, BlogpostListing, BlogpostList (24h cache).
    • Supports overrideSections: map block types to any Section; overrides run before defaults.
    • Renders 16 block types and SEO for post and listing; includes Template for preview.
    • Pagination via page/count props or query params; cache keys include account and page/count/slug.
  • Bug Fixes

    • Added SSR-safe sanitizeHtml/sanitizeHref; applied to all HTML-rendering blocks; Cta validates external links and sets target/rel.
    • Clamped count to max 100 in cacheKey and loader; BlogPostPage uses "" as date fallback and empty author email; listing uses blog name or "Blog" for SEO and handles empty results.
    • SEO sections guard template types; clarified override behavior in blocksToSections (use store __resolveType, keep API props).

Written for commit 980fe1c. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Added Spire blog integration for dynamic blog post and listing pages with full pagination support
    • Introduced rich content formatting blocks including code samples, videos, images, callouts, comparison tables, and interactive elements
    • Added automated SEO optimization with metadata and canonical URL handling for blog content
    • Implemented responsive layouts and preview capabilities for blog pages

Introduces the spire/ app integrating with the Spire Blog API.
Adds an `overrideSections` prop to the app config, allowing users to
replace any default block renderer (e.g. "product-shelf") with a
custom section via the deco admin UI.

Made-with: Cursor
@github-actions
Copy link
Copy Markdown
Contributor

Tagging Options

Should a new tag be published when this PR is merged?

  • 👍 for Patch 0.142.2 update
  • 🎉 for Minor 0.143.0 update
  • 🚀 for Major 1.0.0 update

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 24, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

A new Spire blog integration app is added to the codebase, providing loaders for individual blog posts and paginated post listings, along with associated section components for rendering posts, SEO metadata, and a collection of block components for content structure.

Changes

Cohort / File(s) Summary
App Registration
deco.ts
Added app("spire") to the exported config.apps array.
Loaders
spire/loaders/BlogPostPage.ts, spire/loaders/BlogpostList.ts, spire/loaders/BlogpostListing.ts
Three loaders with 24-hour caching: BlogPostPage fetches individual posts by slug, BlogpostList fetches paginated summaries, and BlogpostListing fetches a full listing page with pagination metadata. All use ctx.api to call Spire endpoints and include conversion utilities (spirePostToBlogPost, spirePostSummaryToBlogPost).
Blog Rendering Sections
spire/sections/Template.tsx, spire/sections/Seo/SeoBlogPost.tsx, spire/sections/Seo/SeoBlogPostListing.tsx
Template renders blog posts with title, excerpt, date, image, and rendered content sections. SEO sections compose metadata from post/listing data, applying template strings and sanitizing JSON-LD payloads.
Block Components
spire/sections/blocks/BlockImage.tsx, spire/sections/blocks/Callout.tsx, spire/sections/blocks/CardGroup.tsx, spire/sections/blocks/Checklist.tsx, spire/sections/blocks/Code.tsx, spire/sections/blocks/Comparison.tsx, spire/sections/blocks/Cta.tsx, spire/sections/blocks/Divider.tsx, spire/sections/blocks/Heading.tsx, spire/sections/blocks/List.tsx, spire/sections/blocks/Paragraph.tsx, spire/sections/blocks/Quote.tsx, spire/sections/blocks/Stat.tsx, spire/sections/blocks/StatGroup.tsx, spire/sections/blocks/Steps.tsx, spire/sections/blocks/Video.tsx
Fifteen block components rendering structured content elements (headings, lists, images, videos, code blocks, callouts, comparisons, statistics, steps). Most accept JSON-encoded or plain-text props and sanitize HTML via sanitizeHtml.
Type Definitions & API Contracts
spire/types.ts, spire/utils/client.ts
types.ts re-exports blog types and defines Spire data interfaces (posts, authors, pagination, and 17 block types as a discriminated union). client.ts exports typed API route contracts for blog listing and post-by-slug endpoints.
Utilities
spire/utils/blocksToSections.ts, spire/utils/sanitizeHtml.ts
blocksToSections maps Block[] to Section[], resolving overrides and routing to appropriate block components. sanitizeHtml and sanitizeHref remove scripts, strip event handlers, and block unsafe protocols via regex.
App Setup & Manifest
spire/mod.ts, spire/manifest.gen.ts
mod.ts exports the main Spire app component, creating an HTTP client, building section overrides, and returning a Deco app instance. manifest.gen.ts (generated) imports all loaders and block components, building a manifest object mapping module paths to their namespaces.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Loader as BlogPostPage Loader
    participant API as Spire API
    participant Converter as spirePostToBlogPost
    participant Template
    participant SEO

    Client->>Loader: Request blog post (slug)
    Loader->>API: GET /blog/:account/posts/:slug
    API-->>Loader: SpirePost (with version blocks)
    Loader->>Converter: Convert SpirePost
    Converter-->>Loader: BlogPost (with sections)
    Loader-->>Template: Return BlogPostPage
    Template->>Template: Render title, excerpt, date, image
    Template->>Template: Map blocks to sections via blocksToSections
    Template-->>Client: Rendered HTML
    SEO->>SEO: Compose metadata (title, description, image, canonical)
    SEO-->>Client: SEO tags & JSON-LD
Loading
sequenceDiagram
    participant Client
    participant Loader as BlogpostListing Loader
    participant API as Spire API
    participant Converter as spirePostSummaryToBlogPost
    participant PageComponent

    Client->>Loader: Request blog listing (page, count)
    Loader->>Loader: Parse & validate page/count
    Loader->>API: GET /blog/:account (with page, perPage)
    API-->>Loader: SpireListingResponse (posts[], pagination, blog)
    Loader->>Converter: Convert each SpirePostSummary
    Converter-->>Loader: BlogPost[]
    Loader->>Loader: Build pageInfo (nextPage, previousPage)
    Loader-->>PageComponent: Return BlogPostListingPage
    PageComponent->>PageComponent: Render posts list with pagination
    PageComponent-->>Client: Rendered HTML
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • add array of sections for blogpost #1536: Adds sections array to BlogPost type and updates Template to render mapped sections, directly supporting the block-rendering architecture introduced here.
  • fix blogpost image #1521: Modifies SEO image-selection logic in blog loaders to prefer post.seo.image before falling back to site-level or post image, aligning with the SEO composition strategy used in SeoBlogPost.tsx.

Suggested reviewers

  • lucasribeirodeco
  • mcandeia

🐰 Spire blog posts now take flight,
With blocks and sections, code's so tight,
HTML sanitized, links made safe,
Loaders cache with graceful escape,
One app to rule them all—pure delight! ✨📚

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main addition: a new Spire blog app with support for overriding block sections via configuration.
Description check ✅ Passed The description covers the main feature, how it works, and includes a test plan, though it lacks the issue link and Loom video sections from the template.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/spire-override-sections

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

11 issues found across 28 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="spire/loaders/BlogpostList.ts">

<violation number="1" location="spire/loaders/BlogpostList.ts:29">
P2: Normalize `page`/`count` in `cacheKey` to avoid cache fragmentation from raw query strings.</violation>

<violation number="2" location="spire/loaders/BlogpostList.ts:41">
P2: Validate and sanitize `page`/`count` before calling the API; `Number(...)` can produce `NaN` for query input.</violation>
</file>

<file name="spire/loaders/BlogpostListing.ts">

<violation number="1" location="spire/loaders/BlogpostListing.ts:44">
P2: Guard against non-numeric `page`/`count` query values so you don’t send `NaN` to the API. Fall back to defaults when parsing fails.</violation>
</file>

<file name="spire/utils/blocksToSections.ts">

<violation number="1" location="spire/utils/blocksToSections.ts:33">
P2: Override sections lose their configured props because only `__resolveType` is read from the override and the returned section is rebuilt from `block.content` only.</violation>
</file>

<file name="spire/sections/blocks/StatGroup.tsx">

<violation number="1" location="spire/sections/blocks/StatGroup.tsx:17">
P1: Validate the parsed JSON type before assigning to `items`; non-array JSON currently causes a runtime crash when `.slice()` is called.</violation>
</file>

<file name="spire/sections/blocks/CardGroup.tsx">

<violation number="1" location="spire/sections/blocks/CardGroup.tsx:17">
P2: Validate the parsed JSON type before using array methods; non-array JSON can crash rendering.</violation>

<violation number="2" location="spire/sections/blocks/CardGroup.tsx:40">
P1: Unsanitized `dangerouslySetInnerHTML` renders user-provided HTML and can introduce XSS.</violation>
</file>

<file name="spire/sections/blocks/Checklist.tsx">

<violation number="1" location="spire/sections/blocks/Checklist.tsx:10">
P2: Validate the parsed JSON is an array before assigning it to `list`; otherwise valid non-array JSON will crash rendering when `.map` is called.</violation>
</file>

<file name="spire/mod.ts">

<violation number="1" location="spire/mod.ts:71">
P3: Spire app metadata uses the Weather logo URL, which causes incorrect branding for this app.</violation>
</file>

<file name="spire/sections/blocks/Steps.tsx">

<violation number="1" location="spire/sections/blocks/Steps.tsx:12">
P2: Validate that parsed JSON is an array before assigning to `list`; otherwise `list.map(...)` can crash at runtime.</violation>
</file>

<file name="spire/sections/blocks/Video.tsx">

<violation number="1" location="spire/sections/blocks/Video.tsx:21">
P1: Validate and restrict `iframe` URLs before assigning `src`; currently untrusted input can be embedded directly when it doesn't match the YouTube/Vimeo rewrite rules.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread spire/sections/blocks/StatGroup.tsx Outdated
Comment thread spire/sections/blocks/CardGroup.tsx Outdated
Comment thread spire/sections/blocks/Video.tsx
Comment thread spire/loaders/BlogpostList.ts Outdated
Comment thread spire/loaders/BlogpostList.ts Outdated
Comment thread spire/utils/blocksToSections.ts Outdated
Comment thread spire/sections/blocks/CardGroup.tsx Outdated
Comment thread spire/sections/blocks/Checklist.tsx Outdated
Comment thread spire/sections/blocks/Steps.tsx Outdated
Comment thread spire/mod.ts Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (4)
spire/sections/blocks/CardGroup.tsx-16-21 (1)

16-21: ⚠️ Potential issue | 🟡 Minor

Guard against valid-but-wrong JSON shapes.

try/catch only handles invalid JSON. Values like "null" or "{}" will parse successfully and then blow up on slice/map. Normalize the parsed value with Array.isArray(...) ? ... : [] before using it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/sections/blocks/CardGroup.tsx` around lines 16 - 21, The JSON parsing
currently only catches invalid JSON but doesn't guard against valid-but-wrong
shapes (e.g. "null" or "{}") which later cause failures when using slice/map;
after parsing the value from cards (and still inside the try/catch), normalize
the parsed result by replacing items with an array only if Array.isArray(parsed)
else fallback to [] (keep the existing catch to set items = [] on parse errors)
so that variables like items, clamped and GRID_CLASS lookups are always working
with an array.
spire/sections/blocks/Steps.tsx-11-13 (1)

11-13: ⚠️ Potential issue | 🟡 Minor

Validate the parsed steps value before iterating it.

A payload like "null" or "{}" passes JSON.parse and then fails at list.map(...). Please coerce non-array results to [] after parsing instead of assuming the JSON shape is always correct.

Also applies to: 22-24

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/sections/blocks/Steps.tsx` around lines 11 - 13, The JSON.parse result
for `steps` must be validated and coerced to an array before use: after the
existing try/catch around `list = JSON.parse(steps ?? "[]");` check if the
parsed value is an Array (e.g. Array.isArray(parsed)) and if not set `list =
[]`; do the same validation/coercion for the other parse block mentioned (the
second JSON.parse at lines 22-24) so any parsed `null` or object becomes an
empty array before calling `list.map(...)` or other array methods.
spire/loaders/BlogpostList.ts-64-76 (1)

64-76: ⚠️ Potential issue | 🟡 Minor

Don’t coerce a missing publish date to "".

SpirePostSummary.publishedAt is nullable, but BlogPost.date is supposed to be a date-formatted string. Returning "" will surface as an invalid date in downstream formatters if Spire sends null. Filter those posts out or model the absence explicitly instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/loaders/BlogpostList.ts` around lines 64 - 76,
spirePostSummaryToBlogPost currently coerces a nullable
SpirePostSummary.publishedAt to "" which yields invalid dates; instead either
filter out summaries with null publishedAt before mapping or preserve the
absence by assigning the raw nullable value (e.g. date: summary.publishedAt) and
update BlogPost.date to be optional/null-able (string | null | undefined) so
downstream formatters can handle missing dates explicitly; locate and change
mapping in spirePostSummaryToBlogPost and adjust the BlogPost type accordingly
or add a pre-filter that removes summaries where publishedAt is null.
spire/mod.ts-71-71 (1)

71-71: ⚠️ Potential issue | 🟡 Minor

Incorrect logo URL - points to weather app instead of Spire.

The logo URLs reference the weather app's logo. Update these to use an appropriate Spire blog logo.

-  * `@logo` https://raw.githubusercontent.com/deco-cx/apps/main/weather/logo.png
+  * `@logo` https://raw.githubusercontent.com/deco-cx/apps/main/spire/logo.png

And in the preview props:

       logo:
-        "https://raw.githubusercontent.com/deco-cx/apps/main/weather/logo.png",
+        "https://raw.githubusercontent.com/deco-cx/apps/main/spire/logo.png",

Also applies to: 97-98

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/mod.ts` at line 71, The manifest's `@logo` annotation and the preview
props currently point to the weather app logo; update both occurrences (the
`@logo` field and the preview image/logo entries inside the preview props) to the
correct Spire blog logo URL by replacing
"https://raw.githubusercontent.com/deco-cx/apps/main/weather/logo.png" with the
appropriate Spire logo URL in spire/mod.ts (look for the `@logo` annotation and
the preview block around the preview props).
🧹 Nitpick comments (6)
spire/loaders/BlogPostPage.ts (3)

77-77: Inconsistent date fallback between loaders.

This loader falls back to new Date().toISOString() when publishedAt is null, while BlogpostList.ts uses an empty string. This inconsistency could cause confusing behavior.

Consider a unified approach - either both should use current date, or both should indicate the absence differently (e.g., a sentinel value or throwing for unpublished posts).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/loaders/BlogPostPage.ts` at line 77, The date fallback in the
BlogPostPage loader is inconsistent with BlogpostList.ts: replace the current
fallback logic in BlogPostPage (where it sets date: post.publishedAt ?? new
Date().toISOString()) with the same strategy used in BlogpostList.ts (either an
empty string or the agreed sentinel) or vice versa so both loaders use the same
behavior; update the expression referencing post.publishedAt in the BlogPostPage
loader to match the chosen unified fallback policy and ensure any downstream
consumers of the date field (renderers or serializers) handle that unified
sentinel consistently.

36-44: Consider adding error logging for failed requests.

Unlike BlogpostListing.ts which logs errors, this loader silently returns null on failure. Adding logging would help with debugging production issues.

♻️ Suggested improvement
+import { logger } from "@deco/deco/o11y";
+
   if (!response.ok) {
+    logger.error(`Failed to fetch Spire post: ${slug}, status: ${response.status}`);
     return null;
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/loaders/BlogPostPage.ts` around lines 36 - 44, The loader currently
returns null silently when the fetch fails or the JSON has no post; update the
error handling in BlogPostPage.ts around the response.ok check and the
subsequent const { post } extraction to log failures (e.g., using the project
logger or console.error) including useful details such as response.status,
response.statusText and any error body or parsed error message so you can
diagnose bad responses and missing post payloads.

71-75: Fabricated email addresses may be misleading.

The email is generated as ${a.slug}@spire.blog, which creates fake email addresses. If the Author type requires an email but Spire doesn't provide one, consider using an empty string or undefined (if the type allows) rather than fabricating addresses that could be mistaken for real contact information.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/loaders/BlogPostPage.ts` around lines 71 - 75, The authors mapping in
BlogPostPage uses a fabricated email `${a.slug}@spire.blog`; change the mapping
in the authors: post.authors.map(...) block to stop constructing fake addresses
— use undefined (or an empty string if the Author type requires it) instead of
`${a.slug}@spire.blog`, and update the Author type or casting if necessary to
accept undefined/email optional; locate the authors mapping and replace email:
`${a.slug}@spire.blog` with email: undefined (or email: '') and adjust types for
post.authors / Author accordingly.
spire/loaders/BlogpostListing.ts (3)

64-71: SEO title is empty - consider using blog metadata.

The seo.title is hardcoded to an empty string. The API response includes blog metadata (per SpireListingResponse in spire/utils/client.ts) which could provide a meaningful title.

♻️ Suggested improvement
-    const { posts: rawPosts, pagination } = await response.json();
+    const { posts: rawPosts, pagination, blog } = await response.json();
     const posts: BlogPost[] = (rawPosts ?? []).map(spirePostSummaryToBlogPost);
     ...
     return {
       posts,
       pageInfo: toPageInfo(pagination, params),
       seo: {
-        title: "",
+        title: blog?.name ?? "",
         canonical: new URL(url.pathname, url.origin).href,
       },
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/loaders/BlogpostListing.ts` around lines 64 - 71, The SEO title is
hardcoded empty in the return object; update the seo.title to use the blog
metadata from the listing response (e.g., response.blog?.title or the variable
holding the SpireListingResponse.blog) and fall back to a sensible default (site
name or "Blog") if missing; modify the object returned in BlogpostListing.ts
(the block that returns posts, pageInfo: toPageInfo(pagination, params), seo: {
title: "", canonical: new URL(url.pathname, url.origin).href }) to set seo.title
to that blog title/fallback.

60-62: Returning null for empty posts may not be the best UX.

When posts.length === 0, the loader returns null. This could happen on a valid page beyond the last page, or when no posts exist. Consider returning an empty listing page instead, so the UI can show a "no posts found" message rather than a 404-like experience.

♻️ Suggested alternative
-    if (posts.length === 0) {
-      return null;
-    }
+    // Allow empty listings - UI can display "no posts" message
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/loaders/BlogpostListing.ts` around lines 60 - 62, The loader in
BlogpostListing.ts currently returns null when posts.length === 0; instead
return a valid, empty listing payload so the UI can render a "no posts found"
state (e.g., return the same shape your loader normally returns but with posts:
[] and total: 0, keeping pagination fields like page/pageSize intact). Update
the branch where posts is empty to construct and return that empty listing
object (referencing the posts variable and the BlogpostListing loader function)
rather than null, and ensure any callers that checked for null now handle an
empty posts array to show the empty-state UI.

29-34: Cache key generation may not handle all edge cases correctly.

Using ?? with url.searchParams.get() won't distinguish between missing params and params with value "0". Also, the cacheKey logic differs slightly from the loader logic (lines 44-45) in how defaults are applied.

Consider using the same resolution logic in both places for consistency:

 export const cacheKey = (props: Props, req: Request, ctx: AppContext) => {
   const url = new URL(req.url);
-  const page = props.page ?? url.searchParams.get("page") ?? 1;
-  const count = props.count ?? url.searchParams.get("count") ?? 12;
+  const page = props.page ?? Number(url.searchParams.get("page")) || 1;
+  const count = props.count ?? Number(url.searchParams.get("count")) || 12;
   return `spire-listing-${ctx.account}-page${page}-count${count}`;
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/loaders/BlogpostListing.ts` around lines 29 - 34, cacheKey currently
uses the nullish coalescing (??) with url.searchParams.get() which treats "0" or
"" incorrectly and is inconsistent with the loader resolution; update cacheKey
to mirror the loader's parameter-resolution logic by explicitly checking for
presence (not just nullish) and parsing numeric strings: resolve page and count
by first checking props.page/props.count (allowing 0), then check
url.searchParams.get("page")/get("count") and treat an empty string as missing,
parse them to numbers with a safe fallback to defaults (1 for page, 12 for
count), and return the same key format, referencing the cacheKey function to
implement this consistent logic (or factor the logic into a shared helper used
by both cacheKey and the loader).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@spire/loaders/BlogpostList.ts`:
- Around line 27-31: Normalize and clamp page/count once and reuse them: inside
cacheKey (exported function cacheKey with Params Props, Request, AppContext)
parse props.page and props.count (falling back to url.searchParams or defaults 1
and 12) into positive integers, clamp page >=1 and count to a sane max (e.g.
100), then build the cache key using these normalized numeric values; also
update the subsequent fetch logic in this file that currently reads
props.page/props.count (the fetch block later in the file) to use the same
normalized variables so the cache key and API call match and huge/invalid values
are prevented.

In `@spire/loaders/BlogPostPage.ts`:
- Line 46: The call site passes ctx.overrideMap which is Record<string,
Resolved<Section>> but spirePostToBlogPost declares overrides as Record<string,
Section>, causing a type mismatch; update spirePostToBlogPost’s overrides
parameter type to Record<string, Resolved<Section>> (or alternatively unwrap the
Resolved<Section> values before calling) and apply the same change where
blocksToSections is invoked—either change blocksToSections to accept
Resolved<Section> or map ctx.overrideMap to produce plain Section instances
before passing; adjust any type annotations and usages inside
spirePostToBlogPost and blocksToSections accordingly so the types line up with
ctx.overrideMap.

In `@spire/sections/blocks/Callout.tsx`:
- Around line 59-63: The Callout component currently injects the raw body prop
into the DOM via dangerouslySetInnerHTML (see the body variable usage in
Callout.tsx); sanitize the HTML before rendering or make the trust boundary
explicit in the prop/type so only already-sanitized/ trusted HTML is accepted.
Update the Callout component to run body through a sanitizer (or validate the
prop type as TrustedHtml) before passing it to dangerouslySetInnerHTML, and
ensure any helper used (e.g., sanitizeHtml or a strip-and-allowlist utility) is
imported and applied where body is used so the XSS sink is eliminated.

In `@spire/sections/blocks/CardGroup.tsx`:
- Around line 38-45: In CardGroup.tsx replace the direct use of
dangerouslySetInnerHTML with sanitized HTML outputs: ensure card.title and
card.body are passed through a trusted sanitization function (e.g., DOMPurify or
sanitize-html) before assigning to dangerouslySetInnerHTML, or alternatively
render them as plain text if HTML isn't required; update the component where
card.title and card.body are used (the <strong ... dangerouslySetInnerHTML={{
__html: card.title }} /> and <p ... dangerouslySetInnerHTML={{ __html: card.body
}} /> lines) to use the sanitized result (e.g., sanitizedTitle/sanitizedBody) or
document/validate the upstream trust guarantee at the boundary where the Spire
JSON is parsed.

In `@spire/sections/blocks/Checklist.tsx`:
- Around line 9-11: The code parses items into `list` and then calls `list.map`,
but `JSON.parse(items ?? "[]")` can return a non-array and cause a runtime
error; update the `try` block in Checklist (where `list` is assigned) to
validate the parsed value (e.g., use a temp `parsed` variable and set `list =
Array.isArray(parsed) ? parsed : []`) so that subsequent `list.map` is always
called on an array; keep the catch behavior but ensure `list` is defaulted to an
empty array when parsing yields a non-array.
- Around line 43-46: The Checklist component is rendering untrusted HTML via
dangerouslySetInnerHTML for the item prop (span with dangerouslySetInnerHTML in
Checklist.tsx), creating an XSS risk; fix by importing and applying a sanitizer
(e.g., DOMPurify) before passing HTML into dangerouslySetInnerHTML (sanitize
item in the rendering path inside Checklist component), or alternatively change
the component API to accept only pre-sanitized HTML (rename prop to
sanitizedItem or add a boolean prop requireSanitized and assert callers provide
sanitized content). Also apply the same fix pattern to the other components that
use dangerouslySetInnerHTML (List.tsx, Paragraph.tsx, Steps.tsx, Comparison.tsx,
CardGroup.tsx, Callout.tsx) so all user-supplied HTML is sanitized or required
to be pre-sanitized.

In `@spire/sections/blocks/Comparison.tsx`:
- Line 36: The span is rendering user-provided HTML (`item`) via
dangerouslySetInnerHTML in Comparison.tsx (both occurrences around the span at
line 36 and the similar one at line 56); sanitize the HTML before rendering by
importing a sanitizer (e.g., DOMPurify) and replacing the raw `item` with the
sanitized output (e.g., sanitized = DOMPurify.sanitize(item)) or, if HTML is not
required, render `item` as plain text instead; update the span usages that call
dangerouslySetInnerHTML to use the sanitized value and ensure DOMPurify is
included where the `item` value is produced or just before rendering.
- Around line 14-18: The JSON parse try/catch only prevents parse errors but
doesn't guarantee the expected shape, so modify the Comparison component to
validate shape after parsing: add a runtime guard (e.g., isColumnShape or
ensureItemsArray) that checks leftCol.items and rightCol.items are arrays and
that each item has the expected fields, and coerce missing/invalid values to an
empty array before calling items.map (use these guards where leftCol and
rightCol are used). Also prevent XSS by sanitizing any HTML rendered via
dangerouslySetInnerHTML (import and use a sanitizer such as DOMPurify or
sanitize-html and sanitize item content before passing it into
dangerouslySetInnerHTML); reference leftCol, rightCol, items, and the
dangerouslySetInnerHTML usages in the Comparison component to locate where to
apply these fixes.

In `@spire/sections/blocks/Cta.tsx`:
- Around line 9-11: The Cta component currently forwards the href prop directly
into the anchor which allows dangerous schemes like javascript:; update Cta to
compute a safeHref before rendering by parsing/inspecting href and allowing only
safe schemes (e.g. http, https, mailto, tel, or hash links), falling back to "#"
when invalid or missing, and use that safeHref in the <a> element; also ensure
any external links set target and rel appropriately (e.g. target="_blank" with
rel="noopener noreferrer") so reference the Cta component and the anchor
element/class attributes when making the change.

In `@spire/sections/blocks/Heading.tsx`:
- Around line 25-30: Heading (and sibling blocks Paragraph, CardGroup, Steps,
List, Quote) currently inject the text prop via dangerouslySetInnerHTML without
sanitization; fix by integrating a client-side sanitizer (e.g., DOMPurify) and
always sanitize the incoming text before rendering. In Heading.tsx sanitize the
text prop (e.g., const safe = DOMPurify.sanitize(text)) and use
dangerouslySetInnerHTML={{ __html: safe }} in the Heading render branches
(h2/h3/h4); apply the same change to the other block components (Paragraph,
CardGroup, Steps, List, Quote) so all server/authoring paths consistently
sanitize, and add the DOMPurify import and a small helper sanitize function if
desired.

In `@spire/sections/blocks/List.tsx`:
- Around line 16-33: The List component currently injects HTML unsafely via
entries.map -> <li ... dangerouslySetInnerHTML={{ __html: item }} />, creating
XSS risk; update the component (and similar components Checklist and Callout) to
sanitize item before setting innerHTML by integrating an allowlist HTML
sanitizer (e.g., DOMPurify) at the component level (sanitize each item and use
the sanitized string in dangerouslySetInnerHTML) or, if HTML is not required,
stop using dangerouslySetInnerHTML and render item as plain text instead.

In `@spire/sections/blocks/Paragraph.tsx`:
- Around line 8-10: The Paragraph component uses dangerouslySetInnerHTML with
html/text (see Paragraph.tsx and blocksToSections.ts) and must sanitize input
first to prevent XSS: add a sanitizeHtml utility (or integrate DOMPurify) to
clean the HTML produced from block.content in blocksToSections.ts, export that
sanitized string, and in Paragraph.tsx import and use the sanitizer so
dangerouslySetInnerHTML receives only the sanitized HTML (or fallback to escaped
text) before rendering; ensure the sanitizer is used wherever block.content/html
are converted to HTML to centralize protection.

In `@spire/sections/blocks/StatGroup.tsx`:
- Around line 16-21: The parsed stats value may not be an array, causing
items.slice to throw; in StatGroup.tsx ensure the result of JSON.parse(stats ??
"[]") is validated and coerced to an array before array operations: after
parsing into items, check Array.isArray(items) (and optionally that each element
matches the expected stat shape) and if not, replace items with an empty array
(or a safe fallback), then compute clamped = items.slice(0, 3) and gridCols =
GRID_CLASS[clamped.length] ?? GRID_CLASS[3]; reference the variables items,
stats, clamped, and GRID_CLASS to locate the change.

In `@spire/sections/blocks/Steps.tsx`:
- Around line 43-50: The component is injecting untrusted HTML from step.title
and step.description via dangerouslySetInnerHTML in Steps.tsx; sanitize these
values before rendering to prevent XSS. Import a trusted sanitizer (e.g.,
DOMPurify or sanitize-html) and run DOMPurify.sanitize(step.title) and
DOMPurify.sanitize(step.description) (or sanitize at data parsing time) and then
pass those sanitized strings to dangerouslySetInnerHTML (or render as plain text
when no markup is expected). Ensure the sanitization step is applied wherever
step objects are created or rendered (referencing step.title and
step.description in Steps.tsx) so only sanitized HTML reaches the DOM.

In `@spire/sections/blocks/Video.tsx`:
- Around line 7-15: The current Video.tsx sets src = url ?? "" and falls back to
embedding any input; change the logic to start with src = null and only set src
when a known provider's regex matches (use the existing ytMatch and vimeoMatch
variable patterns) so you derive embed URLs from an allowlist (YouTube and
Vimeo) and return null / nothing from the component when no provider matched;
ensure any iframe rendering code uses this src variable so arbitrary or
unsupported URLs are never embedded.

In `@spire/sections/Seo/SeoBlogPost.tsx`:
- Around line 20-31: The destructuring currently treats ctx.seo as
Record<string, unknown>, making titleTemplate and descriptionTemplate unknown;
narrow ctx.seo to an object with string templates (or validate and coerce)
before destructuring so renderTemplateString receives strings: e.g., inspect or
type-guard ctx.seo to extract titleTemplate and descriptionTemplate as string
(or fallback to "%s") and then call renderTemplateString with those string
values; reference ctx.seo, titleTemplate, descriptionTemplate, and
renderTemplateString to locate the change in SeoBlogPost.tsx.

In `@spire/sections/Seo/SeoBlogPostListing.tsx`:
- Around line 20-31: ctx.seo is being double-cast to unknown which makes
titleTemplate and descriptionTemplate type unknown and causes TS2345 when passed
into renderTemplateString; add runtime type guards to narrow ctx.seo before
using it: check that (ctx as any).seo is an object and that seo.titleTemplate
and seo.descriptionTemplate are typeof "string" (or derive them via a small
helper like getSeoTemplates(ctx) that returns { titleTemplate?: string;
descriptionTemplate?: string }), then only pass the validated string templates
into renderTemplateString; reference the symbols ctx, seo, titleTemplate,
descriptionTemplate, renderTemplateString, jsonLD and props when locating and
updating the code.

In `@spire/utils/blocksToSections.ts`:
- Around line 31-35: The code path for handling overrides currently throws away
the override object's props and logs its config; update the branch that checks
overrides[block.type] to stop console.log, preserve and merge the override
section props into the created section, and only use the override's
__resolveType (if present) plus the merged props so block.content overrides or
extends the configured override. Specifically, call toSection with the name
overrides[block.type]?.__resolveType ?? block.type and as the props spread the
override object (excluding __resolveType if desired) merged with
...(block.content as Record<string, unknown>) so existing override configuration
is retained and block content applies on top; also remove the debug log to avoid
leaking config.

---

Minor comments:
In `@spire/loaders/BlogpostList.ts`:
- Around line 64-76: spirePostSummaryToBlogPost currently coerces a nullable
SpirePostSummary.publishedAt to "" which yields invalid dates; instead either
filter out summaries with null publishedAt before mapping or preserve the
absence by assigning the raw nullable value (e.g. date: summary.publishedAt) and
update BlogPost.date to be optional/null-able (string | null | undefined) so
downstream formatters can handle missing dates explicitly; locate and change
mapping in spirePostSummaryToBlogPost and adjust the BlogPost type accordingly
or add a pre-filter that removes summaries where publishedAt is null.

In `@spire/mod.ts`:
- Line 71: The manifest's `@logo` annotation and the preview props currently point
to the weather app logo; update both occurrences (the `@logo` field and the
preview image/logo entries inside the preview props) to the correct Spire blog
logo URL by replacing
"https://raw.githubusercontent.com/deco-cx/apps/main/weather/logo.png" with the
appropriate Spire logo URL in spire/mod.ts (look for the `@logo` annotation and
the preview block around the preview props).

In `@spire/sections/blocks/CardGroup.tsx`:
- Around line 16-21: The JSON parsing currently only catches invalid JSON but
doesn't guard against valid-but-wrong shapes (e.g. "null" or "{}") which later
cause failures when using slice/map; after parsing the value from cards (and
still inside the try/catch), normalize the parsed result by replacing items with
an array only if Array.isArray(parsed) else fallback to [] (keep the existing
catch to set items = [] on parse errors) so that variables like items, clamped
and GRID_CLASS lookups are always working with an array.

In `@spire/sections/blocks/Steps.tsx`:
- Around line 11-13: The JSON.parse result for `steps` must be validated and
coerced to an array before use: after the existing try/catch around `list =
JSON.parse(steps ?? "[]");` check if the parsed value is an Array (e.g.
Array.isArray(parsed)) and if not set `list = []`; do the same
validation/coercion for the other parse block mentioned (the second JSON.parse
at lines 22-24) so any parsed `null` or object becomes an empty array before
calling `list.map(...)` or other array methods.

---

Nitpick comments:
In `@spire/loaders/BlogpostListing.ts`:
- Around line 64-71: The SEO title is hardcoded empty in the return object;
update the seo.title to use the blog metadata from the listing response (e.g.,
response.blog?.title or the variable holding the SpireListingResponse.blog) and
fall back to a sensible default (site name or "Blog") if missing; modify the
object returned in BlogpostListing.ts (the block that returns posts, pageInfo:
toPageInfo(pagination, params), seo: { title: "", canonical: new
URL(url.pathname, url.origin).href }) to set seo.title to that blog
title/fallback.
- Around line 60-62: The loader in BlogpostListing.ts currently returns null
when posts.length === 0; instead return a valid, empty listing payload so the UI
can render a "no posts found" state (e.g., return the same shape your loader
normally returns but with posts: [] and total: 0, keeping pagination fields like
page/pageSize intact). Update the branch where posts is empty to construct and
return that empty listing object (referencing the posts variable and the
BlogpostListing loader function) rather than null, and ensure any callers that
checked for null now handle an empty posts array to show the empty-state UI.
- Around line 29-34: cacheKey currently uses the nullish coalescing (??) with
url.searchParams.get() which treats "0" or "" incorrectly and is inconsistent
with the loader resolution; update cacheKey to mirror the loader's
parameter-resolution logic by explicitly checking for presence (not just
nullish) and parsing numeric strings: resolve page and count by first checking
props.page/props.count (allowing 0), then check
url.searchParams.get("page")/get("count") and treat an empty string as missing,
parse them to numbers with a safe fallback to defaults (1 for page, 12 for
count), and return the same key format, referencing the cacheKey function to
implement this consistent logic (or factor the logic into a shared helper used
by both cacheKey and the loader).

In `@spire/loaders/BlogPostPage.ts`:
- Line 77: The date fallback in the BlogPostPage loader is inconsistent with
BlogpostList.ts: replace the current fallback logic in BlogPostPage (where it
sets date: post.publishedAt ?? new Date().toISOString()) with the same strategy
used in BlogpostList.ts (either an empty string or the agreed sentinel) or vice
versa so both loaders use the same behavior; update the expression referencing
post.publishedAt in the BlogPostPage loader to match the chosen unified fallback
policy and ensure any downstream consumers of the date field (renderers or
serializers) handle that unified sentinel consistently.
- Around line 36-44: The loader currently returns null silently when the fetch
fails or the JSON has no post; update the error handling in BlogPostPage.ts
around the response.ok check and the subsequent const { post } extraction to log
failures (e.g., using the project logger or console.error) including useful
details such as response.status, response.statusText and any error body or
parsed error message so you can diagnose bad responses and missing post
payloads.
- Around line 71-75: The authors mapping in BlogPostPage uses a fabricated email
`${a.slug}@spire.blog`; change the mapping in the authors: post.authors.map(...)
block to stop constructing fake addresses — use undefined (or an empty string if
the Author type requires it) instead of `${a.slug}@spire.blog`, and update the
Author type or casting if necessary to accept undefined/email optional; locate
the authors mapping and replace email: `${a.slug}@spire.blog` with email:
undefined (or email: '') and adjust types for post.authors / Author accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c77103f4-6ac1-4893-aeac-060866d66b4f

📥 Commits

Reviewing files that changed from the base of the PR and between 0b95d39 and 631bbd8.

📒 Files selected for processing (28)
  • deco.ts
  • spire/loaders/BlogPostPage.ts
  • spire/loaders/BlogpostList.ts
  • spire/loaders/BlogpostListing.ts
  • spire/manifest.gen.ts
  • spire/mod.ts
  • spire/sections/Seo/SeoBlogPost.tsx
  • spire/sections/Seo/SeoBlogPostListing.tsx
  • spire/sections/Template.tsx
  • spire/sections/blocks/BlockImage.tsx
  • spire/sections/blocks/Callout.tsx
  • spire/sections/blocks/CardGroup.tsx
  • spire/sections/blocks/Checklist.tsx
  • spire/sections/blocks/Code.tsx
  • spire/sections/blocks/Comparison.tsx
  • spire/sections/blocks/Cta.tsx
  • spire/sections/blocks/Divider.tsx
  • spire/sections/blocks/Heading.tsx
  • spire/sections/blocks/List.tsx
  • spire/sections/blocks/Paragraph.tsx
  • spire/sections/blocks/Quote.tsx
  • spire/sections/blocks/Stat.tsx
  • spire/sections/blocks/StatGroup.tsx
  • spire/sections/blocks/Steps.tsx
  • spire/sections/blocks/Video.tsx
  • spire/types.ts
  • spire/utils/blocksToSections.ts
  • spire/utils/client.ts

Comment thread spire/loaders/BlogpostList.ts
Comment thread spire/loaders/BlogPostPage.ts
Comment thread spire/sections/blocks/Callout.tsx
Comment thread spire/sections/blocks/CardGroup.tsx
Comment thread spire/sections/blocks/Checklist.tsx
Comment thread spire/sections/blocks/Steps.tsx Outdated
Comment thread spire/sections/blocks/Video.tsx Outdated
Comment thread spire/sections/Seo/SeoBlogPost.tsx Outdated
Comment thread spire/sections/Seo/SeoBlogPostListing.tsx Outdated
Comment thread spire/utils/blocksToSections.ts Outdated
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 11 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="vtex/loaders/legacy/productList.ts">

<violation number="1" location="vtex/loaders/legacy/productList.ts:139">
P2: Remove this debug `console.log`; it will spam production logs and can leak request/config data from `props`.</violation>
</file>

<file name="spire/sections/blocks/Video.tsx">

<violation number="1" location="spire/sections/blocks/Video.tsx:16">
P2: The new URL whitelist rejects valid YouTube/Vimeo embed URLs, causing previously renderable videos to disappear.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Comment thread vtex/loaders/legacy/productList.ts Outdated
if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;

// Reject anything that didn't match a trusted provider.
return null;
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new URL whitelist rejects valid YouTube/Vimeo embed URLs, causing previously renderable videos to disappear.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At spire/sections/blocks/Video.tsx, line 16:

<comment>The new URL whitelist rejects valid YouTube/Vimeo embed URLs, causing previously renderable videos to disappear.</comment>

<file context>
@@ -3,16 +3,23 @@ export interface Props {
+  if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
+
+  // Reject anything that didn't match a trusted provider.
+  return null;
+}
+
</file context>
Suggested change
return null;
const ytEmbedMatch = raw.match(/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/);
if (ytEmbedMatch) return `https://www.youtube.com/embed/${ytEmbedMatch[1]}`;
const vimeoEmbedMatch = raw.match(/player\.vimeo\.com\/video\/(\d+)/);
if (vimeoEmbedMatch) return `https://player.vimeo.com/video/${vimeoEmbedMatch[1]}`;
return null;
Fix with Cubic

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
spire/loaders/BlogpostListing.ts (1)

28-35: Extract parseIntParam to a shared utility to reduce duplication.

This function is identical to the one in BlogpostList.ts. Consider moving it to a shared location like spire/utils/params.ts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/loaders/BlogpostListing.ts` around lines 28 - 35, Extract the
parseIntParam helper into a shared utility module (e.g., create a new function
export in spire/utils/params.ts) and replace the local definitions in
BlogpostListing (function parseIntParam) and BlogpostList with an import from
that module; ensure the exported name is parseIntParam, keep the same signature
(value: number | string | null | undefined, fallback: number): number, update
both files to import { parseIntParam } and remove the duplicated function, and
run type checks to confirm no API changes.
spire/loaders/BlogPostPage.ts (2)

71-74: Synthetic email addresses may cause confusion.

The email ${a.slug}@spire.blog is fabricated and won't be a real email. Consider using undefined instead, or add a comment clarifying this is a placeholder.

♻️ Proposed change
     authors: post.authors.map((a) => ({
       name: a.name,
-      email: `${a.slug}@spire.blog`,
+      email: undefined, // Spire API doesn't provide author emails
       avatar: a.avatarUrl ?? undefined,
     })),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/loaders/BlogPostPage.ts` around lines 71 - 74, The authors mapping in
BlogPostPage.ts currently assigns a synthetic email `${a.slug}@spire.blog` which
is misleading; change the assignment in the authors: post.authors.map((a) => ({
... })) block to use undefined for the email field (email: undefined) or, if you
must keep a placeholder, replace it with a clearly marked comment and a constant
like email: undefined /* placeholder: synthetic email removed */ so consumers
don't treat it as real; update references to a.slug only for non-email uses and
ensure avatar uses a.avatarUrl ?? undefined as before.

77-77: Date fallback to new Date() may cause cache key instability.

If publishedAt is null, every request generates a new timestamp, which could cause issues with 24-hour caching if the post data is otherwise identical.

♻️ Proposed fix: use a stable fallback
-    date: post.publishedAt ?? new Date().toISOString(),
+    date: post.publishedAt ?? "",

Or use a fixed epoch value if a non-empty date is required.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/loaders/BlogPostPage.ts` at line 77, The current fallback for the
`date` field uses `new Date().toISOString()` (in the BlogPostPage loader
assignment `date: post.publishedAt ?? new Date().toISOString()`), which produces
a different value on every request and breaks cache stability; change the
fallback to a stable value (e.g., `post.createdAt`, a fixed epoch string like
`"1970-01-01T00:00:00.000Z"`, or `null`/empty string if allowed) so the `date`
property is deterministic when `post.publishedAt` is missing and caching won't
be invalidated each request.
spire/loaders/BlogpostList.ts (1)

24-31: Consider adding an upper bound to count to prevent excessive API requests.

parseIntParam validates that values are positive integers but doesn't cap them. A malicious or misconfigured count=999999 would be passed directly to the API.

♻️ Proposed enhancement
 function parseIntParam(
   value: number | string | null | undefined,
   fallback: number,
+  max?: number,
 ): number {
   const n = parseInt(String(value ?? ""), 10);
-  return Number.isFinite(n) && n > 0 ? n : fallback;
+  const valid = Number.isFinite(n) && n > 0 ? n : fallback;
+  return max !== undefined ? Math.min(valid, max) : valid;
 }

Then use parseIntParam(count ?? ..., 12, 100) to cap at a reasonable maximum.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/loaders/BlogpostList.ts` around lines 24 - 31, The parseIntParam
function accepts an unbounded positive integer which allows huge values (e.g.,
count=999999) to be passed to downstream APIs; modify parseIntParam to accept an
optional max parameter (e.g., parseIntParam(value, fallback, max)) and clamp the
parsed result to the range [1, max] returning fallback if out of range/NaN, then
update call sites (e.g., where parseIntParam is used for count) to pass a
reasonable upper bound like 100 (call parseIntParam(count ?? ..., 12, 100)).
spire/mod.ts (1)

49-60: Consider validating that account is non-empty.

If account is an empty string, API calls will fail silently. A validation check or default could improve the developer experience.

♻️ Proposed enhancement
 export default function Spire({ account, overrideSections }: Props) {
+  if (!account?.trim()) {
+    throw new Error("Spire app requires a non-empty account slug");
+  }
+
   const api = createHttpClient<SpireApi>({
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/mod.ts` around lines 49 - 60, Add a runtime validation for
Props.account to ensure it is a non-empty string: when Props is received (e.g.,
in the component/initializer that consumes the Props interface), check
Props.account.trim() !== "" and either throw a clear Error (e.g., "spire:
account must be a non-empty string") or provide a sensible default/fallback and
log a warning; update the code paths that construct or accept Props to perform
this validation before making API calls so empty account values cannot proceed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@spire/loaders/BlogpostList.ts`:
- Line 22: Add the missing semicolon to the exported constant declaration to
satisfy formatting: update the export of the `cache` constant (export const
cache = "no-cache") by terminating the statement with a semicolon so `deno fmt
--check` passes.

In `@spire/loaders/BlogpostListing.ts`:
- Around line 74-77: Add a short inline comment explaining that the empty
seo.title in the loader is intentional as a fallback because the
SeoBlogPostListing component uses a Title Override prop that takes precedence;
update the object constructed in the BlogpostListing loader (the seo: { title:
"", canonical: ... } block) to include a comment like "Intentional default;
override via SeoBlogPostListing's title prop" next to title to make the intent
explicit for future readers.

In `@vtex/loaders/legacy/productList.ts`:
- Around line 139-140: Delete the stray debug statement console.log("lEGACY",
props); from vtex/loaders/legacy/productList.ts (it appears inside the product
list loader where props is available); remove the line entirely (or replace with
a proper structured logger call if intentional) and search for the "lEGACY"
string to ensure no other debug logs remain before merging.

---

Nitpick comments:
In `@spire/loaders/BlogpostList.ts`:
- Around line 24-31: The parseIntParam function accepts an unbounded positive
integer which allows huge values (e.g., count=999999) to be passed to downstream
APIs; modify parseIntParam to accept an optional max parameter (e.g.,
parseIntParam(value, fallback, max)) and clamp the parsed result to the range
[1, max] returning fallback if out of range/NaN, then update call sites (e.g.,
where parseIntParam is used for count) to pass a reasonable upper bound like 100
(call parseIntParam(count ?? ..., 12, 100)).

In `@spire/loaders/BlogpostListing.ts`:
- Around line 28-35: Extract the parseIntParam helper into a shared utility
module (e.g., create a new function export in spire/utils/params.ts) and replace
the local definitions in BlogpostListing (function parseIntParam) and
BlogpostList with an import from that module; ensure the exported name is
parseIntParam, keep the same signature (value: number | string | null |
undefined, fallback: number): number, update both files to import {
parseIntParam } and remove the duplicated function, and run type checks to
confirm no API changes.

In `@spire/loaders/BlogPostPage.ts`:
- Around line 71-74: The authors mapping in BlogPostPage.ts currently assigns a
synthetic email `${a.slug}@spire.blog` which is misleading; change the
assignment in the authors: post.authors.map((a) => ({ ... })) block to use
undefined for the email field (email: undefined) or, if you must keep a
placeholder, replace it with a clearly marked comment and a constant like email:
undefined /* placeholder: synthetic email removed */ so consumers don't treat it
as real; update references to a.slug only for non-email uses and ensure avatar
uses a.avatarUrl ?? undefined as before.
- Line 77: The current fallback for the `date` field uses `new
Date().toISOString()` (in the BlogPostPage loader assignment `date:
post.publishedAt ?? new Date().toISOString()`), which produces a different value
on every request and breaks cache stability; change the fallback to a stable
value (e.g., `post.createdAt`, a fixed epoch string like
`"1970-01-01T00:00:00.000Z"`, or `null`/empty string if allowed) so the `date`
property is deterministic when `post.publishedAt` is missing and caching won't
be invalidated each request.

In `@spire/mod.ts`:
- Around line 49-60: Add a runtime validation for Props.account to ensure it is
a non-empty string: when Props is received (e.g., in the component/initializer
that consumes the Props interface), check Props.account.trim() !== "" and either
throw a clear Error (e.g., "spire: account must be a non-empty string") or
provide a sensible default/fallback and log a warning; update the code paths
that construct or accept Props to perform this validation before making API
calls so empty account values cannot proceed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e1684ca5-332e-4286-9a72-42d5476cf8c2

📥 Commits

Reviewing files that changed from the base of the PR and between 631bbd8 and f7a1323.

📒 Files selected for processing (11)
  • spire/loaders/BlogPostPage.ts
  • spire/loaders/BlogpostList.ts
  • spire/loaders/BlogpostListing.ts
  • spire/mod.ts
  • spire/sections/blocks/CardGroup.tsx
  • spire/sections/blocks/Checklist.tsx
  • spire/sections/blocks/StatGroup.tsx
  • spire/sections/blocks/Steps.tsx
  • spire/sections/blocks/Video.tsx
  • spire/utils/blocksToSections.ts
  • vtex/loaders/legacy/productList.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • spire/sections/blocks/StatGroup.tsx
  • spire/sections/blocks/Video.tsx

Comment thread spire/loaders/BlogpostList.ts Outdated
* @title BlogpostList
* @description Retrieves a list of Spire blog posts.
*/
export const cache = "no-cache"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix formatting: missing semicolon causing CI failure.

The pipeline reports that deno fmt --check failed on this line.

🐛 Proposed fix
-export const cache = "no-cache"
+export const cache = "no-cache";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const cache = "no-cache"
export const cache = "no-cache";
🧰 Tools
🪛 GitHub Actions: ci

[error] 22-22: Deno fmt --check failed. File is not formatted: expected 'export const cache = "no-cache";' (formatting difference reported on line 22).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/loaders/BlogpostList.ts` at line 22, Add the missing semicolon to the
exported constant declaration to satisfy formatting: update the export of the
`cache` constant (export const cache = "no-cache") by terminating the statement
with a semicolon so `deno fmt --check` passes.

Comment on lines +74 to +77
seo: {
title: "",
canonical: new URL(url.pathname, url.origin).href,
},
Copy link
Copy Markdown
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

🏁 Script executed:

#!/bin/bash
# Check how seo.title is used in SEO components
rg -n "seo\?.title|seo\.title" spire/ -A 2 -B 2

Repository: deco-cx/apps

Length of output: 771


🏁 Script executed:

# View the BlogpostListing.ts file around the seo section
cat -n spire/loaders/BlogpostListing.ts | sed -n '60,90p'

Repository: deco-cx/apps

Length of output: 961


🏁 Script executed:

# Check how SeoBlogPostListing.tsx uses the data from the loader
cat -n spire/sections/Seo/SeoBlogPostListing.tsx

Repository: deco-cx/apps

Length of output: 2038


🏁 Script executed:

# Look for any comments or context around the seo object in BlogpostListing
rg -n "seo\s*:\s*\{" spire/loaders/BlogpostListing.ts -A 5 -B 5

Repository: deco-cx/apps

Length of output: 292


🏁 Script executed:

# Find usages of SeoBlogPostListing component to see if titleProp is always provided
rg -n "SeoBlogPostListing" spire/ -A 3 -B 1

Repository: deco-cx/apps

Length of output: 682


🏁 Script executed:

# Also check if there's any documentation or usage pattern for BlogpostListing loader
rg -n "BlogpostListing" spire/ -A 2 -B 1 | head -50

Repository: deco-cx/apps

Length of output: 1098


🏁 Script executed:

# Search for template or page files that might instantiate SeoBlogPostListing with or without titleProp
find spire -type f \( -name "*.tsx" -o -name "*.ts" \) | xargs grep -l "SeoBlogPostListing" | head -10

Repository: deco-cx/apps

Length of output: 77


🏁 Script executed:

# Look for actual page/template instantiations that use the loader
rg -n "BlogpostListing" spire --type tsx --type ts -C 5 | grep -A 10 -B 10 "title"

Repository: deco-cx/apps

Length of output: 83


🏁 Script executed:

# Find the BlogPostListingPage type definition
rg -n "BlogPostListingPage" spire/ -A 5 -B 2

Repository: deco-cx/apps

Length of output: 2431


🏁 Script executed:

# Check if there are any example files or tests that show usage
find spire -type f \( -name "*example*" -o -name "*test*" -o -name "*demo*" \) 2>/dev/null

Repository: deco-cx/apps

Length of output: 38


🏁 Script executed:

# Look at the complete BlogpostListing.ts file to see if there's any documentation
cat -n spire/loaders/BlogpostListing.ts

Repository: deco-cx/apps

Length of output: 3826


Add a comment to explain the intentional empty seo.title in the loader.

The empty title is by design—the SeoBlogPostListing section component receives the listing data from this loader and provides a "Title Override" prop that takes precedence. The empty string serves as the fallback in the chain: titleProp || jsonLD?.seo?.title || "". However, the loader code should include a comment clarifying this intention, such as:

seo: {
  title: "", // Intentional default; override via SeoBlogPostListing's title prop
  canonical: new URL(url.pathname, url.origin).href,
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@spire/loaders/BlogpostListing.ts` around lines 74 - 77, Add a short inline
comment explaining that the empty seo.title in the loader is intentional as a
fallback because the SeoBlogPostListing component uses a Title Override prop
that takes precedence; update the object constructed in the BlogpostListing
loader (the seo: { title: "", canonical: ... } block) to include a comment like
"Intentional default; override via SeoBlogPostListing's title prop" next to
title to make the intent explicit for future readers.

Comment thread vtex/loaders/legacy/productList.ts Outdated
… and correctness

- Add sanitizeHtml/sanitizeHref utility (SSR-safe, no external deps) and apply
  it to all block components that use dangerouslySetInnerHTML: Callout, CardGroup,
  Checklist, Comparison, Heading, List, Paragraph, Steps
- Cta: validate href scheme via sanitizeHref; add target/rel for external links
- Comparison: normalize parsed JSON shape (title/items) with runtime guards so
  items is always an array before .map()
- BlogpostList: clamp count to MAX_COUNT (100) in both cacheKey and loader
- BlogPostPage: use "" as date fallback (consistent with BlogpostList); log errors
  before returning null; replace fabricated author email with ""
- BlogpostListing: return empty listing instead of null when posts is empty;
  set seo.title from blog.name with "Blog" fallback
- SeoBlogPost/SeoBlogPostListing: narrow ctx.seo titleTemplate and
  descriptionTemplate to string via typeof guards before renderTemplateString
- blocksToSections: add comment clarifying override intent (use store resolveType,
  keep API props)

Made-with: Cursor
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 16 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="spire/utils/sanitizeHtml.ts">

<violation number="1" location="spire/utils/sanitizeHtml.ts:17">
P1: XSS bypass via HTML entity encoding: `href="&#106;avascript:alert(1)"` evades the literal `javascript` check but the browser decodes entities before interpreting the protocol. Regex-based protocol detection on raw HTML cannot reliably catch encoded variants. Consider decoding HTML entities in attribute values before matching, or using a DOM-based sanitizer (e.g., DOMPurify or the Sanitizer API) that operates on parsed HTML rather than raw strings.</violation>

<violation number="2" location="spire/utils/sanitizeHtml.ts:21">
P1: XSS bypass: the `javascript:`/`data:` protocol stripping only handles quoted attribute values. Unquoted values like `<a href=javascript:alert(1)>` pass through unsanitized and execute when rendered via `dangerouslySetInnerHTML`. Add a third `.replace()` for unquoted attribute values.</violation>
</file>

<file name="spire/sections/blocks/Comparison.tsx">

<violation number="1" location="spire/sections/blocks/Comparison.tsx:19">
P2: `items` is not narrowed to strings before calling `sanitizeHtml(item)`, so non-string array entries can cause a runtime crash (`raw.replace is not a function`).</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

.replace(/<style\b[\s\S]*?<\/style\s*>/gi, "")
.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
.replace(
/\b(href|src|action)\s*=\s*"(?:javascript|data)[^"]*"/gi,
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: XSS bypass via HTML entity encoding: href="&#106;avascript:alert(1)" evades the literal javascript check but the browser decodes entities before interpreting the protocol. Regex-based protocol detection on raw HTML cannot reliably catch encoded variants. Consider decoding HTML entities in attribute values before matching, or using a DOM-based sanitizer (e.g., DOMPurify or the Sanitizer API) that operates on parsed HTML rather than raw strings.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At spire/utils/sanitizeHtml.ts, line 17:

<comment>XSS bypass via HTML entity encoding: `href="&#106;avascript:alert(1)"` evades the literal `javascript` check but the browser decodes entities before interpreting the protocol. Regex-based protocol detection on raw HTML cannot reliably catch encoded variants. Consider decoding HTML entities in attribute values before matching, or using a DOM-based sanitizer (e.g., DOMPurify or the Sanitizer API) that operates on parsed HTML rather than raw strings.</comment>

<file context>
@@ -0,0 +1,43 @@
+    .replace(/<style\b[\s\S]*?<\/style\s*>/gi, "")
+    .replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
+    .replace(
+      /\b(href|src|action)\s*=\s*"(?:javascript|data)[^"]*"/gi,
+      '$1="#"',
+    )
</file context>
Fix with Cubic

'$1="#"',
)
.replace(
/\b(href|src|action)\s*=\s*'(?:javascript|data)[^']*'/gi,
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: XSS bypass: the javascript:/data: protocol stripping only handles quoted attribute values. Unquoted values like <a href=javascript:alert(1)> pass through unsanitized and execute when rendered via dangerouslySetInnerHTML. Add a third .replace() for unquoted attribute values.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At spire/utils/sanitizeHtml.ts, line 21:

<comment>XSS bypass: the `javascript:`/`data:` protocol stripping only handles quoted attribute values. Unquoted values like `<a href=javascript:alert(1)>` pass through unsanitized and execute when rendered via `dangerouslySetInnerHTML`. Add a third `.replace()` for unquoted attribute values.</comment>

<file context>
@@ -0,0 +1,43 @@
+      '$1="#"',
+    )
+    .replace(
+      /\b(href|src|action)\s*=\s*'(?:javascript|data)[^']*'/gi,
+      "$1='#'",
+    );
</file context>
Fix with Cubic

const parsed = JSON.parse(left ?? "{}");
leftCol = {
title: typeof parsed?.title === "string" ? parsed.title : "Option A",
items: Array.isArray(parsed?.items) ? parsed.items : [],
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: items is not narrowed to strings before calling sanitizeHtml(item), so non-string array entries can cause a runtime crash (raw.replace is not a function).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At spire/sections/blocks/Comparison.tsx, line 19:

<comment>`items` is not narrowed to strings before calling `sanitizeHtml(item)`, so non-string array entries can cause a runtime crash (`raw.replace is not a function`).</comment>

<file context>
@@ -11,10 +13,18 @@ export default function Comparison({ left, right }: Props) {
+    const parsed = JSON.parse(left ?? "{}");
+    leftCol = {
+      title: typeof parsed?.title === "string" ? parsed.title : "Option A",
+      items: Array.isArray(parsed?.items) ? parsed.items : [],
+    };
   } catch { /* ignore */ }
</file context>
Fix with Cubic

@guitavano guitavano merged commit c5f405c into main Apr 2, 2026
7 of 8 checks passed
@guitavano guitavano deleted the feat/spire-override-sections branch April 2, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants