Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions deco.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const config = {
app("implementation"),
app("weather"),
app("blog"),
app("spire"),
app("analytics"),
app("sourei"),
app("typesense"),
Expand Down
86 changes: 86 additions & 0 deletions spire/loaders/BlogPostPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { STALE } from "../../utils/fetch.ts";
import { AppContext } from "../mod.ts";
import { BlogPost, BlogPostPage, SpirePost } from "../types.ts";
import { blocksToSections } from "../utils/blocksToSections.ts";
import type { RequestURLParam } from "../../website/functions/requestToParam.ts";

export interface Props {
slug: RequestURLParam;
}

/**
* @title BlogPostPage
* @description Fetches a specific Spire blog post page by its slug.
*/
export const cache = {
maxAge: 60 * 60 * 24, // 24 hours
};

export const cacheKey = (props: Props, _req: Request, ctx: AppContext) => {
return `spire-post-${ctx.account}-${props.slug}`;
};

export default async function BlogPostPageLoader(
{ slug }: Props,
req: Request,
ctx: AppContext,
): Promise<BlogPostPage | null> {
const { account, api } = ctx;
const url = new URL(req.url);

const response = await api["GET /blog/:account/posts/:slug"](
{ account, slug },
STALE,
);

if (!response.ok) {
return null;
}

const { post } = await response.json();

if (!post) {
return null;
}

const blogPost = spirePostToBlogPost(post, ctx.overrideMap);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return {
"@type": "BlogPostPage",
post: blogPost,
seo: {
title: post.version.metaTitle || post.version.title,
description: post.version.metaDescription || post.version.description,
image: post.version.imageUrl,
canonical: url.href,
noIndexing: false,
},
};
}

export function spirePostToBlogPost(
post: SpirePost,
overrides: Record<string, import("@deco/deco/blocks").Section> = {},
): BlogPost {
return {
id: post.id,
title: post.version.title,
excerpt: post.version.description,
image: post.version.imageUrl,
alt: post.version.title,
authors: post.authors.map((a) => ({
name: a.name,
email: `${a.slug}@spire.blog`,
avatar: a.avatarUrl ?? undefined,
})),
categories: [],
date: post.publishedAt ?? new Date().toISOString(),
slug: post.slug,
seo: {
title: post.version.metaTitle,
description: post.version.metaDescription,
image: post.version.imageUrl,
},
sections: blocksToSections(post.version.blocks, overrides),
};
}
78 changes: 78 additions & 0 deletions spire/loaders/BlogpostList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { logger } from "@deco/deco/o11y";
import { STALE } from "../../utils/fetch.ts";
import { AppContext } from "../mod.ts";
import { BlogPost, SpirePostSummary } from "../types.ts";

export interface Props {
/**
* @title Items per page
* @description Number of posts per page to display.
*/
count?: number;
/**
* @title Page number
* @description The current page number. Defaults to 1.
*/
page?: number;
}

/**
* @title BlogpostList
* @description Retrieves a list of Spire blog posts.
*/
export const cache = {
maxAge: 60 * 60 * 24, // 24 hours
};

export const cacheKey = (props: Props, req: Request, ctx: AppContext) => {
const url = new URL(req.url);
const page = props.page ?? url.searchParams.get("page") ?? 1;
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
const count = props.count ?? url.searchParams.get("count") ?? 12;
return `spire-list-${ctx.account}-page${page}-count${count}`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

export default async function BlogpostList(
{ page, count }: Props,
req: Request,
ctx: AppContext,
): Promise<BlogPost[]> {
const { account, api } = ctx;
const url = new URL(req.url);
const perPage = Number(count ?? url.searchParams.get("count") ?? 12);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
const pageNumber = Number(page ?? url.searchParams.get("page") ?? 1);

try {
const response = await api["GET /blog/:account"](
{ account, page: pageNumber, perPage },
STALE,
);

if (!response.ok) {
return [];
}

const { posts } = await response.json();
const blogPosts = (posts ?? []).map(spirePostSummaryToBlogPost);

return blogPosts.length > 0 ? blogPosts : [];
} catch (e) {
logger.error(e);
return [];
}
}

export function spirePostSummaryToBlogPost(
summary: SpirePostSummary,
): BlogPost {
return {
id: summary.id,
title: summary.title,
excerpt: summary.description,
image: summary.imageUrl,
alt: summary.title,
authors: [],
categories: [],
date: summary.publishedAt ?? "",
slug: summary.slug,
};
}
99 changes: 99 additions & 0 deletions spire/loaders/BlogpostListing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { logger } from "@deco/deco/o11y";
import { PageInfo } from "../../commerce/types.ts";
import { STALE } from "../../utils/fetch.ts";
import { AppContext } from "../mod.ts";
import { BlogPost, BlogPostListingPage, SpirePagination } from "../types.ts";
import { spirePostSummaryToBlogPost } from "./BlogpostList.ts";

export interface Props {
/**
* @title Items per page
* @description Number of posts per page to display.
*/
count?: number;
/**
* @title Page number
* @description The current page number. Defaults to 1.
*/
page?: number;
}

/**
* @title BlogpostListing
* @description Retrieves a paginated listing page of Spire blog posts.
*/
export const cache = {
maxAge: 60 * 60 * 24, // 24 hours
};

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;
return `spire-listing-${ctx.account}-page${page}-count${count}`;
};

export default async function BlogpostListing(
{ page, count }: Props,
req: Request,
ctx: AppContext,
): Promise<BlogPostListingPage | null> {
const { account, api } = ctx;
const url = new URL(req.url);
const params = url.searchParams;
const perPage = Number(count ?? params.get("count") ?? 12);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
const pageNumber = Number(page ?? params.get("page") ?? 1);

try {
const response = await api["GET /blog/:account"](
{ account, page: pageNumber, perPage },
STALE,
);

if (!response.ok) {
return null;
}

const { posts: rawPosts, pagination } = await response.json();
const posts: BlogPost[] = (rawPosts ?? []).map(spirePostSummaryToBlogPost);

if (posts.length === 0) {
return null;
}

return {
posts,
pageInfo: toPageInfo(pagination, params),
seo: {
title: "",
canonical: new URL(url.pathname, url.origin).href,
},
Comment on lines +70 to +73

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.

};
} catch (e) {
logger.error(e);
return null;
}
}

function toPageInfo(
pagination: SpirePagination,
params: URLSearchParams,
): PageInfo {
const { page, totalPages, total, perPage } = pagination;
const hasNextPage = page < totalPages;
const hasPrevPage = page > 1;

const nextPageParams = new URLSearchParams(params);
const prevPageParams = new URLSearchParams(params);

if (hasNextPage) nextPageParams.set("page", String(page + 1));
if (hasPrevPage) prevPageParams.set("page", String(page - 1));

return {
nextPage: hasNextPage ? `?${nextPageParams}` : undefined,
previousPage: hasPrevPage ? `?${prevPageParams}` : undefined,
currentPage: page,
records: total,
recordPerPage: perPage,
};
}
61 changes: 61 additions & 0 deletions spire/manifest.gen.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// DO NOT EDIT. This file is generated by deco.
// This file SHOULD be checked into source version control.
// This file is automatically updated during development when running `dev.ts`.

import * as $$$1 from "./loaders/BlogpostList.ts";
import * as $$$2 from "./loaders/BlogpostListing.ts";
import * as $$$0 from "./loaders/BlogPostPage.ts";
import * as $$$$$$3 from "./sections/blocks/BlockImage.tsx";
import * as $$$$$$4 from "./sections/blocks/Callout.tsx";
import * as $$$$$$5 from "./sections/blocks/CardGroup.tsx";
import * as $$$$$$6 from "./sections/blocks/Checklist.tsx";
import * as $$$$$$7 from "./sections/blocks/Code.tsx";
import * as $$$$$$8 from "./sections/blocks/Comparison.tsx";
import * as $$$$$$9 from "./sections/blocks/Cta.tsx";
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 $$$$$$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 $$$$$$18 from "./sections/blocks/Video.tsx";
import * as $$$$$$0 from "./sections/Seo/SeoBlogPost.tsx";
import * as $$$$$$1 from "./sections/Seo/SeoBlogPostListing.tsx";
import * as $$$$$$2 from "./sections/Template.tsx";

const manifest = {
"loaders": {
"spire/loaders/BlogpostList.ts": $$$1,
"spire/loaders/BlogpostListing.ts": $$$2,
"spire/loaders/BlogPostPage.ts": $$$0,
},
"sections": {
"spire/sections/blocks/BlockImage.tsx": $$$$$$3,
"spire/sections/blocks/Callout.tsx": $$$$$$4,
"spire/sections/blocks/CardGroup.tsx": $$$$$$5,
"spire/sections/blocks/Checklist.tsx": $$$$$$6,
"spire/sections/blocks/Code.tsx": $$$$$$7,
"spire/sections/blocks/Comparison.tsx": $$$$$$8,
"spire/sections/blocks/Cta.tsx": $$$$$$9,
"spire/sections/blocks/Divider.tsx": $$$$$$10,
"spire/sections/blocks/Heading.tsx": $$$$$$11,
"spire/sections/blocks/List.tsx": $$$$$$12,
"spire/sections/blocks/Paragraph.tsx": $$$$$$13,
"spire/sections/blocks/Quote.tsx": $$$$$$14,
"spire/sections/blocks/Stat.tsx": $$$$$$15,
"spire/sections/blocks/StatGroup.tsx": $$$$$$16,
"spire/sections/blocks/Steps.tsx": $$$$$$17,
"spire/sections/blocks/Video.tsx": $$$$$$18,
"spire/sections/Seo/SeoBlogPost.tsx": $$$$$$0,
"spire/sections/Seo/SeoBlogPostListing.tsx": $$$$$$1,
"spire/sections/Template.tsx": $$$$$$2,
},
"name": "spire",
"baseUrl": import.meta.url,
};

export type Manifest = typeof manifest;

export default manifest;
Loading
Loading