Skip to content

Commit c5f405c

Browse files
authored
feat: add spire blog app with overrideSections support (#1561)
* feat: add spire blog app with overrideSections support 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 * fix cubic requests * fix cacheKey * remove logs * fix(spire): address code review findings — sanitization, type safety, 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
1 parent dd2bbdc commit c5f405c

29 files changed

Lines changed: 1739 additions & 0 deletions

deco.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const config = {
6161
app("implementation"),
6262
app("weather"),
6363
app("blog"),
64+
app("spire"),
6465
app("analytics"),
6566
app("sourei"),
6667
app("typesense"),

spire/loaders/BlogPostPage.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { logger } from "@deco/deco/o11y";
2+
import { AppContext } from "../mod.ts";
3+
import { BlogPost, BlogPostPage, SpirePost } from "../types.ts";
4+
import { blocksToSections } from "../utils/blocksToSections.ts";
5+
import type { RequestURLParam } from "../../website/functions/requestToParam.ts";
6+
import { Resolved } from "@deco/deco";
7+
import { Section } from "@deco/deco/blocks";
8+
9+
export interface Props {
10+
slug: RequestURLParam;
11+
}
12+
13+
/**
14+
* @title BlogPostPage
15+
* @description Fetches a specific Spire blog post page by its slug.
16+
*/
17+
export const cache = {
18+
maxAge: 60 * 60 * 24, // 24 hours
19+
};
20+
21+
export const cacheKey = (props: Props, _req: Request, ctx: AppContext) => {
22+
return `spire-post-${ctx.account}-${props.slug}`;
23+
};
24+
25+
export default async function BlogPostPageLoader(
26+
{ slug }: Props,
27+
req: Request,
28+
ctx: AppContext,
29+
): Promise<BlogPostPage | null> {
30+
const { account, api } = ctx;
31+
const url = new URL(req.url);
32+
33+
const response = await api["GET /blog/:account/posts/:slug"](
34+
{ account, slug },
35+
);
36+
37+
if (!response.ok) {
38+
logger.error(
39+
`BlogPostPage: fetch failed for slug "${slug}" — ${response.status} ${response.statusText}`,
40+
);
41+
return null;
42+
}
43+
44+
const { post } = await response.json();
45+
46+
if (!post) {
47+
logger.error(`BlogPostPage: no post found for slug "${slug}"`);
48+
return null;
49+
}
50+
51+
const blogPost = spirePostToBlogPost(post, ctx.overrideMap);
52+
53+
return {
54+
"@type": "BlogPostPage",
55+
post: blogPost,
56+
seo: {
57+
title: post.version.metaTitle || post.version.title,
58+
description: post.version.metaDescription || post.version.description,
59+
image: post.version.imageUrl,
60+
canonical: url.href,
61+
noIndexing: false,
62+
},
63+
};
64+
}
65+
66+
export function spirePostToBlogPost(
67+
post: SpirePost,
68+
overrides: Record<string, Resolved<Section>> = {},
69+
): BlogPost {
70+
return {
71+
id: post.id,
72+
title: post.version.title,
73+
excerpt: post.version.description,
74+
image: post.version.imageUrl,
75+
alt: post.version.title,
76+
authors: post.authors.map((a) => ({
77+
name: a.name,
78+
email: "",
79+
avatar: a.avatarUrl ?? undefined,
80+
})),
81+
categories: [],
82+
date: post.publishedAt ?? "",
83+
slug: post.slug,
84+
seo: {
85+
title: post.version.metaTitle,
86+
description: post.version.metaDescription,
87+
image: post.version.imageUrl,
88+
},
89+
sections: blocksToSections(post.version.blocks, overrides),
90+
};
91+
}

spire/loaders/BlogpostList.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { logger } from "@deco/deco/o11y";
2+
import { AppContext } from "../mod.ts";
3+
import { BlogPost, SpirePostSummary } from "../types.ts";
4+
5+
export interface Props {
6+
/**
7+
* @title Items per page
8+
* @description Number of posts per page to display.
9+
*/
10+
count?: number;
11+
/**
12+
* @title Page number
13+
* @description The current page number. Defaults to 1.
14+
*/
15+
page?: number;
16+
}
17+
18+
/**
19+
* @title BlogpostList
20+
* @description Retrieves a list of Spire blog posts.
21+
*/
22+
export const cache = {
23+
maxAge: 60 * 60 * 24, // 24 hours
24+
};
25+
26+
const MAX_COUNT = 100;
27+
28+
/** Parse an integer from a value that may be a number, string, or null. Falls back to `fallback` on NaN/null. */
29+
function parseIntParam(
30+
value: number | string | null | undefined,
31+
fallback: number,
32+
): number {
33+
const n = parseInt(String(value ?? ""), 10);
34+
return Number.isFinite(n) && n > 0 ? n : fallback;
35+
}
36+
37+
export const cacheKey = (props: Props, req: Request, ctx: AppContext) => {
38+
const url = new URL(req.url);
39+
const page = parseIntParam(props.page ?? url.searchParams.get("page"), 1);
40+
const count = Math.min(
41+
parseIntParam(props.count ?? url.searchParams.get("count"), 12),
42+
MAX_COUNT,
43+
);
44+
return `spire-list-${ctx.account}-page${page}-count${count}`;
45+
};
46+
47+
export default async function BlogpostList(
48+
{ page, count }: Props,
49+
req: Request,
50+
ctx: AppContext,
51+
): Promise<BlogPost[]> {
52+
const { account, api } = ctx;
53+
const url = new URL(req.url);
54+
const perPage = Math.min(
55+
parseIntParam(count ?? url.searchParams.get("count"), 12),
56+
MAX_COUNT,
57+
);
58+
const pageNumber = parseIntParam(page ?? url.searchParams.get("page"), 1);
59+
60+
try {
61+
const response = await api["GET /blog/:account"](
62+
{ account, page: pageNumber, perPage },
63+
);
64+
65+
if (!response.ok) {
66+
return [];
67+
}
68+
69+
const { posts } = await response.json();
70+
const blogPosts = (posts ?? []).map(spirePostSummaryToBlogPost);
71+
72+
return blogPosts.length > 0 ? blogPosts : [];
73+
} catch (e) {
74+
logger.error(e);
75+
return [];
76+
}
77+
}
78+
79+
export function spirePostSummaryToBlogPost(
80+
summary: SpirePostSummary,
81+
): BlogPost {
82+
return {
83+
id: summary.id,
84+
title: summary.title,
85+
excerpt: summary.description,
86+
image: summary.imageUrl,
87+
alt: summary.title,
88+
authors: [],
89+
categories: [],
90+
date: summary.publishedAt ?? "",
91+
slug: summary.slug,
92+
};
93+
}

spire/loaders/BlogpostListing.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { logger } from "@deco/deco/o11y";
2+
import { PageInfo } from "../../commerce/types.ts";
3+
import { AppContext } from "../mod.ts";
4+
import { BlogPost, BlogPostListingPage, SpirePagination } from "../types.ts";
5+
import { spirePostSummaryToBlogPost } from "./BlogpostList.ts";
6+
7+
export interface Props {
8+
/**
9+
* @title Items per page
10+
* @description Number of posts per page to display.
11+
*/
12+
count?: number;
13+
/**
14+
* @title Page number
15+
* @description The current page number. Defaults to 1.
16+
*/
17+
page?: number;
18+
}
19+
20+
/**
21+
* @title BlogpostListing
22+
* @description Retrieves a paginated listing page of Spire blog posts.
23+
*/
24+
export const cache = {
25+
maxAge: 60 * 60 * 24, // 24 hours
26+
};
27+
28+
/** Parse an integer from a value that may be a number, string, or null. Falls back to `fallback` on NaN/null. */
29+
function parseIntParam(
30+
value: number | string | null | undefined,
31+
fallback: number,
32+
): number {
33+
const n = parseInt(String(value ?? ""), 10);
34+
return Number.isFinite(n) && n > 0 ? n : fallback;
35+
}
36+
37+
export const cacheKey = (props: Props, req: Request, ctx: AppContext) => {
38+
const url = new URL(req.url);
39+
const page = parseIntParam(props.page ?? url.searchParams.get("page"), 1);
40+
const count = parseIntParam(props.count ?? url.searchParams.get("count"), 12);
41+
return `spire-listing-${ctx.account}-page${page}-count${count}`;
42+
};
43+
44+
export default async function BlogpostListing(
45+
{ page, count }: Props,
46+
req: Request,
47+
ctx: AppContext,
48+
): Promise<BlogPostListingPage | null> {
49+
const { account, api } = ctx;
50+
const url = new URL(req.url);
51+
const params = url.searchParams;
52+
const perPage = parseIntParam(count ?? params.get("count"), 12);
53+
const pageNumber = parseIntParam(page ?? params.get("page"), 1);
54+
55+
try {
56+
const response = await api["GET /blog/:account"](
57+
{ account, page: pageNumber, perPage },
58+
);
59+
60+
if (!response.ok) {
61+
return null;
62+
}
63+
64+
const { posts: rawPosts, pagination, blog } = await response.json();
65+
const posts: BlogPost[] = (rawPosts ?? []).map(spirePostSummaryToBlogPost);
66+
67+
return {
68+
posts,
69+
pageInfo: toPageInfo(pagination, params),
70+
seo: {
71+
title: blog?.name || "Blog",
72+
canonical: new URL(url.pathname, url.origin).href,
73+
},
74+
};
75+
} catch (e) {
76+
logger.error(e);
77+
return null;
78+
}
79+
}
80+
81+
function toPageInfo(
82+
pagination: SpirePagination,
83+
params: URLSearchParams,
84+
): PageInfo {
85+
const { page, totalPages, total, perPage } = pagination;
86+
const hasNextPage = page < totalPages;
87+
const hasPrevPage = page > 1;
88+
89+
const nextPageParams = new URLSearchParams(params);
90+
const prevPageParams = new URLSearchParams(params);
91+
92+
if (hasNextPage) nextPageParams.set("page", String(page + 1));
93+
if (hasPrevPage) prevPageParams.set("page", String(page - 1));
94+
95+
return {
96+
nextPage: hasNextPage ? `?${nextPageParams}` : undefined,
97+
previousPage: hasPrevPage ? `?${prevPageParams}` : undefined,
98+
currentPage: page,
99+
records: total,
100+
recordPerPage: perPage,
101+
};
102+
}

spire/manifest.gen.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// DO NOT EDIT. This file is generated by deco.
2+
// This file SHOULD be checked into source version control.
3+
// This file is automatically updated during development when running `dev.ts`.
4+
5+
import * as $$$1 from "./loaders/BlogpostList.ts";
6+
import * as $$$2 from "./loaders/BlogpostListing.ts";
7+
import * as $$$0 from "./loaders/BlogPostPage.ts";
8+
import * as $$$$$$3 from "./sections/blocks/BlockImage.tsx";
9+
import * as $$$$$$4 from "./sections/blocks/Callout.tsx";
10+
import * as $$$$$$5 from "./sections/blocks/CardGroup.tsx";
11+
import * as $$$$$$6 from "./sections/blocks/Checklist.tsx";
12+
import * as $$$$$$7 from "./sections/blocks/Code.tsx";
13+
import * as $$$$$$8 from "./sections/blocks/Comparison.tsx";
14+
import * as $$$$$$9 from "./sections/blocks/Cta.tsx";
15+
import * as $$$$$$10 from "./sections/blocks/Divider.tsx";
16+
import * as $$$$$$11 from "./sections/blocks/Heading.tsx";
17+
import * as $$$$$$12 from "./sections/blocks/List.tsx";
18+
import * as $$$$$$13 from "./sections/blocks/Paragraph.tsx";
19+
import * as $$$$$$14 from "./sections/blocks/Quote.tsx";
20+
import * as $$$$$$15 from "./sections/blocks/Stat.tsx";
21+
import * as $$$$$$16 from "./sections/blocks/StatGroup.tsx";
22+
import * as $$$$$$17 from "./sections/blocks/Steps.tsx";
23+
import * as $$$$$$18 from "./sections/blocks/Video.tsx";
24+
import * as $$$$$$0 from "./sections/Seo/SeoBlogPost.tsx";
25+
import * as $$$$$$1 from "./sections/Seo/SeoBlogPostListing.tsx";
26+
import * as $$$$$$2 from "./sections/Template.tsx";
27+
28+
const manifest = {
29+
"loaders": {
30+
"spire/loaders/BlogpostList.ts": $$$1,
31+
"spire/loaders/BlogpostListing.ts": $$$2,
32+
"spire/loaders/BlogPostPage.ts": $$$0,
33+
},
34+
"sections": {
35+
"spire/sections/blocks/BlockImage.tsx": $$$$$$3,
36+
"spire/sections/blocks/Callout.tsx": $$$$$$4,
37+
"spire/sections/blocks/CardGroup.tsx": $$$$$$5,
38+
"spire/sections/blocks/Checklist.tsx": $$$$$$6,
39+
"spire/sections/blocks/Code.tsx": $$$$$$7,
40+
"spire/sections/blocks/Comparison.tsx": $$$$$$8,
41+
"spire/sections/blocks/Cta.tsx": $$$$$$9,
42+
"spire/sections/blocks/Divider.tsx": $$$$$$10,
43+
"spire/sections/blocks/Heading.tsx": $$$$$$11,
44+
"spire/sections/blocks/List.tsx": $$$$$$12,
45+
"spire/sections/blocks/Paragraph.tsx": $$$$$$13,
46+
"spire/sections/blocks/Quote.tsx": $$$$$$14,
47+
"spire/sections/blocks/Stat.tsx": $$$$$$15,
48+
"spire/sections/blocks/StatGroup.tsx": $$$$$$16,
49+
"spire/sections/blocks/Steps.tsx": $$$$$$17,
50+
"spire/sections/blocks/Video.tsx": $$$$$$18,
51+
"spire/sections/Seo/SeoBlogPost.tsx": $$$$$$0,
52+
"spire/sections/Seo/SeoBlogPostListing.tsx": $$$$$$1,
53+
"spire/sections/Template.tsx": $$$$$$2,
54+
},
55+
"name": "spire",
56+
"baseUrl": import.meta.url,
57+
};
58+
59+
export type Manifest = typeof manifest;
60+
61+
export default manifest;

0 commit comments

Comments
 (0)