@decocms/apps-blog 7.52.0 → 7.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/package.json +13 -4
  2. package/src/README.md +69 -0
  3. package/src/__tests__/blogRecords.test.ts +270 -0
  4. package/src/__tests__/date.test.ts +134 -0
  5. package/src/__tests__/handlePosts.test.ts +230 -230
  6. package/src/__tests__/loaders.test.ts +154 -154
  7. package/src/__tests__/mod.test.ts +6 -6
  8. package/src/__tests__/publicationStatus.test.ts +176 -0
  9. package/src/__tests__/records.test.ts +68 -68
  10. package/src/__tests__/seoSections.test.ts +245 -0
  11. package/src/actions/submitRating.ts +36 -0
  12. package/src/actions/submitReview.ts +95 -0
  13. package/src/actions/submitView.ts +28 -0
  14. package/src/client.ts +69 -0
  15. package/src/core/blogRecords.ts +94 -0
  16. package/src/core/handlePosts.ts +154 -63
  17. package/src/core/records.ts +147 -12
  18. package/src/index.ts +48 -14
  19. package/src/loaderMap.ts +28 -24
  20. package/src/loaders/BlogPostItem.ts +15 -5
  21. package/src/loaders/BlogPostPage.ts +25 -20
  22. package/src/loaders/BlogRelatedPosts.ts +45 -45
  23. package/src/loaders/BlogpostList.ts +73 -0
  24. package/src/loaders/BlogpostListing.ts +114 -77
  25. package/src/loaders/GetCategories.ts +42 -28
  26. package/src/loaders/extensions/BlogpostList/ratings.ts +36 -0
  27. package/src/loaders/extensions/BlogpostList/reviews.ts +36 -0
  28. package/src/loaders/extensions/BlogpostListing/ratings.ts +38 -0
  29. package/src/loaders/extensions/BlogpostListing/reviews.ts +38 -0
  30. package/src/loaders/extensions/BlogpostPage/ratings.ts +24 -0
  31. package/src/loaders/extensions/BlogpostPage/reviews.ts +31 -0
  32. package/src/loaders/extensions/types.ts +15 -0
  33. package/src/manifest.gen.ts +82 -14
  34. package/src/mod.ts +71 -20
  35. package/src/registry.ts +5 -5
  36. package/src/sections/Seo/SeoBlogPost.tsx +75 -0
  37. package/src/sections/Seo/SeoBlogPostListing.tsx +80 -0
  38. package/src/sections/Template.tsx +96 -0
  39. package/src/sections/blocks/BlockImage.tsx +21 -0
  40. package/src/sections/blocks/Callout.tsx +64 -0
  41. package/src/sections/blocks/CardGroup.tsx +60 -0
  42. package/src/sections/blocks/Checklist.tsx +58 -0
  43. package/src/sections/blocks/Code.tsx +29 -0
  44. package/src/sections/blocks/Comparison.tsx +77 -0
  45. package/src/sections/blocks/Cta.tsx +22 -0
  46. package/src/sections/blocks/Divider.tsx +3 -0
  47. package/src/sections/blocks/Heading.tsx +28 -0
  48. package/src/sections/blocks/List.tsx +39 -0
  49. package/src/sections/blocks/Paragraph.tsx +15 -0
  50. package/src/sections/blocks/Quote.tsx +32 -0
  51. package/src/sections/blocks/Stat.tsx +23 -0
  52. package/src/sections/blocks/StatGroup.tsx +38 -0
  53. package/src/sections/blocks/Steps.tsx +64 -0
  54. package/src/sections/blocks/Table.tsx +80 -0
  55. package/src/sections/blocks/Video.tsx +39 -0
  56. package/src/static/css.ts +178 -0
  57. package/src/types.ts +312 -77
  58. package/src/utils/blocksToSections.ts +170 -0
  59. package/src/utils/constants.ts +1 -0
  60. package/src/utils/date.ts +95 -0
  61. package/src/utils/hardSanitize.ts +106 -0
  62. package/src/utils/jsonLD.ts +115 -0
  63. package/src/utils/sanitizeHtml.ts +37 -0
@@ -0,0 +1,38 @@
1
+ import { getRatings } from "../../../core/records";
2
+ import type { BlogPostListingPage, Ignore } from "../../../types";
3
+ import type { ExtensionOf } from "../types";
4
+
5
+ export interface Props {
6
+ /**
7
+ * @description Ignore ratings in the aggregateRating calc
8
+ */
9
+ ignoreRatings?: Ignore;
10
+ /**
11
+ * @description Return only aggregate rating object
12
+ */
13
+ onlyAggregate?: boolean;
14
+ }
15
+
16
+ /**
17
+ * @title ExtensionOf BlogPostListing: Ratings
18
+ * @description It can harm performance. Use wisely
19
+ */
20
+ export default function ratingsExt({
21
+ ignoreRatings,
22
+ onlyAggregate,
23
+ }: Props): ExtensionOf<BlogPostListingPage | null> {
24
+ return async (blogpostListingPage: BlogPostListingPage | null) => {
25
+ if (!blogpostListingPage) {
26
+ return null;
27
+ }
28
+
29
+ const posts = await Promise.all(
30
+ blogpostListingPage.posts.map(async (post) => {
31
+ const ratings = await getRatings({ post, onlyAggregate, ignoreRatings });
32
+ return { ...post, ...ratings };
33
+ }),
34
+ );
35
+
36
+ return { ...blogpostListingPage, posts };
37
+ };
38
+ }
@@ -0,0 +1,38 @@
1
+ import { getReviews } from "../../../core/records";
2
+ import type { BlogPostListingPage, Ignore } from "../../../types";
3
+ import type { ExtensionOf } from "../types";
4
+
5
+ export interface Props {
6
+ /**
7
+ * @description Ignore specific reviews
8
+ */
9
+ ignoreReviews?: Ignore;
10
+ /**
11
+ * @description Order By
12
+ */
13
+ orderBy?: "date_asc" | "date_desc";
14
+ }
15
+
16
+ /**
17
+ * @title ExtensionOf BlogPostListing: Reviews
18
+ * @description It can harm performance. Use wisely
19
+ */
20
+ export default function reviewsExt({
21
+ ignoreReviews,
22
+ orderBy,
23
+ }: Props): ExtensionOf<BlogPostListingPage | null> {
24
+ return async (blogpostListingPage: BlogPostListingPage | null) => {
25
+ if (!blogpostListingPage) {
26
+ return null;
27
+ }
28
+
29
+ const posts = await Promise.all(
30
+ blogpostListingPage.posts.map(async (post) => {
31
+ const reviews = await getReviews({ post, ignoreReviews, orderBy });
32
+ return { ...post, ...reviews };
33
+ }),
34
+ );
35
+
36
+ return { ...blogpostListingPage, posts };
37
+ };
38
+ }
@@ -0,0 +1,24 @@
1
+ import { getRatings } from "../../../core/records";
2
+ import type { BlogPostPage, Ignore } from "../../../types";
3
+ import type { ExtensionOf } from "../types";
4
+
5
+ export interface Props {
6
+ /**
7
+ * @description Ignore ratings in the aggregateRating calc
8
+ */
9
+ ignoreRatings?: Ignore;
10
+ }
11
+
12
+ /**
13
+ * @title ExtensionOf BlogPostPage: Ratings
14
+ * @description It can harm performance. Use wisely
15
+ */
16
+ export default function ratingsExt({ ignoreRatings }: Props): ExtensionOf<BlogPostPage | null> {
17
+ return async (blogpostPage: BlogPostPage | null) => {
18
+ if (!blogpostPage) {
19
+ return null;
20
+ }
21
+ const post = await getRatings({ post: blogpostPage.post, ignoreRatings });
22
+ return { ...blogpostPage, post };
23
+ };
24
+ }
@@ -0,0 +1,31 @@
1
+ import { getReviews } from "../../../core/records";
2
+ import type { BlogPostPage, Ignore } from "../../../types";
3
+ import type { ExtensionOf } from "../types";
4
+
5
+ export interface Props {
6
+ /**
7
+ * @description Ignore specific reviews
8
+ */
9
+ ignoreReviews?: Ignore;
10
+ /**
11
+ * @description Order By
12
+ */
13
+ orderBy?: "date_asc" | "date_desc";
14
+ }
15
+
16
+ /**
17
+ * @title ExtensionOf BlogPostPage: Reviews
18
+ * @description It can harm performance. Use wisely
19
+ */
20
+ export default function reviewsExt({
21
+ ignoreReviews,
22
+ orderBy,
23
+ }: Props): ExtensionOf<BlogPostPage | null> {
24
+ return async (blogpostPage: BlogPostPage | null) => {
25
+ if (!blogpostPage) {
26
+ return null;
27
+ }
28
+ const post = await getReviews({ post: blogpostPage.post, ignoreReviews, orderBy });
29
+ return { ...blogpostPage, post };
30
+ };
31
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * An enrichment step applied to a loader's already-resolved output.
3
+ *
4
+ * In `deco-cx/apps` this type comes from `website/loaders/extension.ts`, which
5
+ * also ships the generic `extension` loader that composes a list of these over
6
+ * one data source. That loader has not been ported into
7
+ * `@decocms/apps-website`, so the type is declared here rather than imported —
8
+ * the *composition* plumbing is a website-app concern and porting it into
9
+ * apps-blog would put it in the wrong package (see this package's README for
10
+ * the tracked gap).
11
+ *
12
+ * Each extension below is therefore usable directly: call it to obtain the
13
+ * enrichment function, then apply that to the loader result.
14
+ */
15
+ export type ExtensionOf<T> = (data: T) => Promise<T>;
@@ -1,26 +1,94 @@
1
- // AUTO-GENERATED by scripts/generate-manifests.ts — DO NOT EDIT
2
- // This file is checked into source control and updated via: npm run generate:manifests
1
+ // AUTO-GENERATED shape, HAND-MAINTAINED file.
2
+ // `scripts/generate-manifests.ts` was never ported into this repo (deferred in
3
+ // docs/apps-monorepo-migration-plan.md); until it is, add new loaders/actions/
4
+ // sections here by hand. Checked into source control (see .gitignore's
5
+ // `!packages/apps-*/src/manifest.gen.ts` negation).
6
+ import * as actions_submitRating from "./actions/submitRating";
7
+ import * as actions_submitReview from "./actions/submitReview";
8
+ import * as actions_submitView from "./actions/submitView";
3
9
  import * as loaders_Author from "./loaders/Author";
10
+ import * as loaders_BlogPostItem from "./loaders/BlogPostItem";
4
11
  import * as loaders_BlogPostPage from "./loaders/BlogPostPage";
5
12
  import * as loaders_Blogpost from "./loaders/Blogpost";
13
+ import * as loaders_BlogpostList from "./loaders/BlogpostList";
6
14
  import * as loaders_BlogpostListing from "./loaders/BlogpostListing";
7
15
  import * as loaders_BlogRelatedPosts from "./loaders/BlogRelatedPosts";
8
16
  import * as loaders_Category from "./loaders/Category";
17
+ import * as loaders_ext_BlogpostList_ratings from "./loaders/extensions/BlogpostList/ratings";
18
+ import * as loaders_ext_BlogpostList_reviews from "./loaders/extensions/BlogpostList/reviews";
19
+ import * as loaders_ext_BlogpostListing_ratings from "./loaders/extensions/BlogpostListing/ratings";
20
+ import * as loaders_ext_BlogpostListing_reviews from "./loaders/extensions/BlogpostListing/reviews";
21
+ import * as loaders_ext_BlogpostPage_ratings from "./loaders/extensions/BlogpostPage/ratings";
22
+ import * as loaders_ext_BlogpostPage_reviews from "./loaders/extensions/BlogpostPage/reviews";
9
23
  import * as loaders_GetCategories from "./loaders/GetCategories";
10
24
 
25
+ const sections_Seo_SeoBlogPost = () => import("./sections/Seo/SeoBlogPost");
26
+ const sections_Seo_SeoBlogPostListing = () => import("./sections/Seo/SeoBlogPostListing");
27
+ const sections_Template = () => import("./sections/Template");
28
+ const sections_blocks_BlockImage = () => import("./sections/blocks/BlockImage");
29
+ const sections_blocks_Callout = () => import("./sections/blocks/Callout");
30
+ const sections_blocks_CardGroup = () => import("./sections/blocks/CardGroup");
31
+ const sections_blocks_Checklist = () => import("./sections/blocks/Checklist");
32
+ const sections_blocks_Code = () => import("./sections/blocks/Code");
33
+ const sections_blocks_Comparison = () => import("./sections/blocks/Comparison");
34
+ const sections_blocks_Cta = () => import("./sections/blocks/Cta");
35
+ const sections_blocks_Divider = () => import("./sections/blocks/Divider");
36
+ const sections_blocks_Heading = () => import("./sections/blocks/Heading");
37
+ const sections_blocks_List = () => import("./sections/blocks/List");
38
+ const sections_blocks_Paragraph = () => import("./sections/blocks/Paragraph");
39
+ const sections_blocks_Quote = () => import("./sections/blocks/Quote");
40
+ const sections_blocks_Stat = () => import("./sections/blocks/Stat");
41
+ const sections_blocks_StatGroup = () => import("./sections/blocks/StatGroup");
42
+ const sections_blocks_Steps = () => import("./sections/blocks/Steps");
43
+ const sections_blocks_Table = () => import("./sections/blocks/Table");
44
+ const sections_blocks_Video = () => import("./sections/blocks/Video");
45
+
11
46
  const manifest = {
12
- name: "blog",
13
- loaders: {
14
- "blog/loaders/Author": loaders_Author,
15
- "blog/loaders/Blogpost": loaders_Blogpost,
16
- "blog/loaders/BlogPostPage": loaders_BlogPostPage,
17
- "blog/loaders/BlogpostListing": loaders_BlogpostListing,
18
- "blog/loaders/BlogRelatedPosts": loaders_BlogRelatedPosts,
19
- "blog/loaders/Category": loaders_Category,
20
- "blog/loaders/GetCategories": loaders_GetCategories,
21
- },
22
- actions: {},
23
- sections: {},
47
+ name: "blog",
48
+ loaders: {
49
+ "blog/loaders/Author": loaders_Author,
50
+ "blog/loaders/Blogpost": loaders_Blogpost,
51
+ "blog/loaders/BlogPostItem": loaders_BlogPostItem,
52
+ "blog/loaders/BlogPostPage": loaders_BlogPostPage,
53
+ "blog/loaders/BlogpostList": loaders_BlogpostList,
54
+ "blog/loaders/BlogpostListing": loaders_BlogpostListing,
55
+ "blog/loaders/BlogRelatedPosts": loaders_BlogRelatedPosts,
56
+ "blog/loaders/Category": loaders_Category,
57
+ "blog/loaders/GetCategories": loaders_GetCategories,
58
+ "blog/loaders/extensions/BlogpostList/ratings": loaders_ext_BlogpostList_ratings,
59
+ "blog/loaders/extensions/BlogpostList/reviews": loaders_ext_BlogpostList_reviews,
60
+ "blog/loaders/extensions/BlogpostListing/ratings": loaders_ext_BlogpostListing_ratings,
61
+ "blog/loaders/extensions/BlogpostListing/reviews": loaders_ext_BlogpostListing_reviews,
62
+ "blog/loaders/extensions/BlogpostPage/ratings": loaders_ext_BlogpostPage_ratings,
63
+ "blog/loaders/extensions/BlogpostPage/reviews": loaders_ext_BlogpostPage_reviews,
64
+ },
65
+ actions: {
66
+ "blog/actions/submitRating": actions_submitRating,
67
+ "blog/actions/submitReview": actions_submitReview,
68
+ "blog/actions/submitView": actions_submitView,
69
+ },
70
+ sections: {
71
+ "blog/sections/Seo/SeoBlogPost": sections_Seo_SeoBlogPost,
72
+ "blog/sections/Seo/SeoBlogPostListing": sections_Seo_SeoBlogPostListing,
73
+ "blog/sections/Template": sections_Template,
74
+ "blog/sections/blocks/BlockImage": sections_blocks_BlockImage,
75
+ "blog/sections/blocks/Callout": sections_blocks_Callout,
76
+ "blog/sections/blocks/CardGroup": sections_blocks_CardGroup,
77
+ "blog/sections/blocks/Checklist": sections_blocks_Checklist,
78
+ "blog/sections/blocks/Code": sections_blocks_Code,
79
+ "blog/sections/blocks/Comparison": sections_blocks_Comparison,
80
+ "blog/sections/blocks/Cta": sections_blocks_Cta,
81
+ "blog/sections/blocks/Divider": sections_blocks_Divider,
82
+ "blog/sections/blocks/Heading": sections_blocks_Heading,
83
+ "blog/sections/blocks/List": sections_blocks_List,
84
+ "blog/sections/blocks/Paragraph": sections_blocks_Paragraph,
85
+ "blog/sections/blocks/Quote": sections_blocks_Quote,
86
+ "blog/sections/blocks/Stat": sections_blocks_Stat,
87
+ "blog/sections/blocks/StatGroup": sections_blocks_StatGroup,
88
+ "blog/sections/blocks/Steps": sections_blocks_Steps,
89
+ "blog/sections/blocks/Table": sections_blocks_Table,
90
+ "blog/sections/blocks/Video": sections_blocks_Video,
91
+ },
24
92
  } as const;
25
93
 
26
94
  export type Manifest = typeof manifest;
package/src/mod.ts CHANGED
@@ -2,14 +2,17 @@
2
2
  * Blog app module — standard autoconfig contract.
3
3
  *
4
4
  * Exports `configure` following the AppModContract pattern.
5
- * Provides blog post, category, and author loaders for the site.
5
+ * Provides blog post, category, and author loaders, the SEO sections, and the
6
+ * post-content section blocks for the site.
6
7
  */
7
8
 
8
- import type {
9
- AppDefinition,
10
- ResolveSecretFn,
11
- } from "@decocms/apps-commerce/app-types";
9
+ import type { AppDefinition, AppHandler, ResolveSecretFn } from "@decocms/apps-commerce/app-types";
10
+ import submitRating from "./actions/submitRating";
11
+ import submitReview from "./actions/submitReview";
12
+ import submitView from "./actions/submitView";
13
+ import { type BlogConfig, type BlogSeoDefaults, configureBlog } from "./client";
12
14
  import manifest from "./manifest.gen";
15
+ import type { Publisher } from "./types";
13
16
 
14
17
  // -------------------------------------------------------------------------
15
18
  // CMS Props
@@ -17,33 +20,81 @@ import manifest from "./manifest.gen";
17
20
 
18
21
  /** @title Deco Blog */
19
22
  export interface Props {
20
- /**
21
- * @title Page Slug
22
- * @description The slug of the BlogPostPage to embed. Use :category and :slug.
23
- */
24
- pageSlug?: string;
23
+ /**
24
+ * @title Category Slug
25
+ * @description The slug of the Categories of the blogposts. Use :category
26
+ * @example /blog/:category
27
+ */
28
+ categorySlug?: string;
29
+ /**
30
+ * @title Page Slug
31
+ * @description The slug of the BlogPostPage to embed. Use :category and :slug.
32
+ * @example /blog/:category/:slug
33
+ */
34
+ pageSlug?: string;
35
+ /**
36
+ * @title Canonical Base URL
37
+ * @description Overrides the origin of the url/mainEntityOfPage emitted in the JSON-LD by the SEO sections, which otherwise use the request host.
38
+ * @example https://www.mysite.com
39
+ */
40
+ canonicalBaseUrl?: string;
41
+ /**
42
+ * @title Publisher
43
+ * @description Emitted as the publisher of the blog posts in the JSON-LD.
44
+ */
45
+ publisher?: Publisher;
46
+ /**
47
+ * @title SEO defaults
48
+ * @description Title/description templates folded into the output of the blog's SEO sections.
49
+ */
50
+ seo?: BlogSeoDefaults;
25
51
  }
26
52
 
27
53
  export type BlogState = Props;
28
54
 
55
+ /**
56
+ * Action entrypoints, exposed under both key forms the resolver may use.
57
+ * Mirrors `@decocms/apps-resend`'s `handlers` map.
58
+ */
59
+ export const handlers: Record<string, AppHandler> = {
60
+ "blog/actions/submitRating": (props) => submitRating(props),
61
+ "blog/actions/submitRating.ts": (props) => submitRating(props),
62
+ "blog/actions/submitReview": (props) => submitReview(props),
63
+ "blog/actions/submitReview.ts": (props) => submitReview(props),
64
+ "blog/actions/submitView": (props) => submitView(props),
65
+ "blog/actions/submitView.ts": (props) => submitView(props),
66
+ };
67
+
29
68
  // -------------------------------------------------------------------------
30
69
  // Configure
31
70
  // -------------------------------------------------------------------------
32
71
 
33
72
  /**
34
73
  * Configure the Blog app from CMS block data.
35
- * Always returns an AppDefinition (no required fields).
74
+ * Always returns an AppDefinition — every field is optional, so a blog with no
75
+ * block configured still resolves and renders.
36
76
  */
37
77
  export async function configure(
38
- // biome-ignore lint/suspicious/noExplicitAny: block data comes from CMS with no fixed schema
39
- _block: any,
40
- _resolveSecret: ResolveSecretFn,
78
+ block: any,
79
+ _resolveSecret: ResolveSecretFn,
41
80
  ): Promise<AppDefinition<BlogState>> {
42
- return {
43
- name: "blog",
44
- manifest,
45
- state: { pageSlug: _block?.pageSlug },
46
- };
81
+ const config: BlogConfig = {
82
+ categorySlug: block?.categorySlug,
83
+ pageSlug: block?.pageSlug,
84
+ canonicalBaseUrl: block?.canonicalBaseUrl,
85
+ publisher: block?.publisher,
86
+ seo: block?.seo,
87
+ };
88
+
89
+ // Bridge: the loaders and sections read this singleton in place of the Deno
90
+ // app's `ctx`, which has no equivalent here.
91
+ configureBlog(config);
92
+
93
+ return {
94
+ name: "blog",
95
+ manifest,
96
+ state: config,
97
+ };
47
98
  }
48
99
 
49
100
  /** Placeholder preview for CMS editor. */
@@ -51,5 +102,5 @@ export const preview = undefined;
51
102
 
52
103
  /** Default export for schema generation and Deno-style app bridges. */
53
104
  export default function Blog(state: Props) {
54
- return { state };
105
+ return { state };
55
106
  }
package/src/registry.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { AppRegistryEntry } from "@decocms/apps-commerce/registry";
2
2
 
3
3
  export const BLOG_REGISTRY_ENTRY: AppRegistryEntry = {
4
- blockKey: "deco-blog",
5
- module: () => import("./mod"),
6
- displayName: "Blog",
7
- category: "content",
8
- description: "Blog posts, categories, and authors from CMS collections",
4
+ blockKey: "deco-blog",
5
+ module: () => import("./mod"),
6
+ displayName: "Blog",
7
+ category: "content",
8
+ description: "Blog posts, categories, and authors from CMS collections",
9
9
  };
@@ -0,0 +1,75 @@
1
+ import SeoComponent, {
2
+ renderTemplateString,
3
+ type SEOSection,
4
+ } from "@decocms/apps-website/components/Seo";
5
+ import { type BlogSeoDefaults, getBlogConfig } from "../../client";
6
+ import type { BlogPostPage } from "../../types";
7
+ import { toBlogPosting, toBreadcrumbList, withCanonicalBase } from "../../utils/jsonLD";
8
+
9
+ export type Props = {
10
+ /** @title Data Source */
11
+ jsonLD: BlogPostPage | null;
12
+ /** @title Title Override */
13
+ title?: string;
14
+ /** @title Description Override */
15
+ description?: string;
16
+ };
17
+
18
+ /**
19
+ * @title Blog Post details
20
+ *
21
+ * Mirrors `SeoV2`'s contract in `@decocms/apps-website`: the site-level SEO
22
+ * defaults arrive as the third argument when the framework threads them
23
+ * through, and otherwise come from the blog app's own config.
24
+ */
25
+ export function loader(props: Props, req?: Request, seo?: BlogSeoDefaults) {
26
+ const rawSeo: BlogSeoDefaults = seo ?? getBlogConfig().seo ?? {};
27
+ const titleTemplate = typeof rawSeo.titleTemplate === "string" ? rawSeo.titleTemplate : "%s";
28
+ const descriptionTemplate =
29
+ typeof rawSeo.descriptionTemplate === "string" ? rawSeo.descriptionTemplate : "%s";
30
+ const { titleTemplate: _tt, descriptionTemplate: _dt, ...seoSiteProps } = rawSeo;
31
+
32
+ const { title: titleProp, description: descriptionProp, jsonLD } = props;
33
+
34
+ const title = renderTemplateString(titleTemplate, titleProp || jsonLD?.seo?.title || "");
35
+ const description = renderTemplateString(
36
+ descriptionTemplate,
37
+ descriptionProp || jsonLD?.seo?.description || "",
38
+ );
39
+
40
+ const image = jsonLD?.post?.seo?.image || jsonLD?.seo?.image || jsonLD?.post?.image;
41
+ const { canonicalBaseUrl, publisher } = getBlogConfig();
42
+ const requestUrl = req?.url ?? canonicalBaseUrl ?? "http://localhost/";
43
+ // Configured canonicals may be relative; resolve against the request URL
44
+ const canonical = jsonLD?.seo?.canonical
45
+ ? withCanonicalBase(new URL(jsonLD.seo.canonical, requestUrl).href, canonicalBaseUrl)
46
+ : undefined;
47
+ const noIndexing = !jsonLD || jsonLD.seo?.noIndexing;
48
+
49
+ const pageUrl = canonical ?? withCanonicalBase(requestUrl, canonicalBaseUrl);
50
+ const jsonLDs = jsonLD?.post
51
+ ? [
52
+ toBlogPosting(jsonLD.post, pageUrl, publisher),
53
+ toBreadcrumbList(pageUrl, {
54
+ currentName: jsonLD.post.title,
55
+ categories: jsonLD.post.categories,
56
+ }),
57
+ ]
58
+ : [];
59
+
60
+ return {
61
+ ...seoSiteProps,
62
+ title,
63
+ description,
64
+ image,
65
+ canonical,
66
+ noIndexing,
67
+ jsonLDs,
68
+ };
69
+ }
70
+
71
+ function Section(props: Props): SEOSection {
72
+ return <SeoComponent {...props} />;
73
+ }
74
+
75
+ export default Section;
@@ -0,0 +1,80 @@
1
+ import SeoComponent, {
2
+ renderTemplateString,
3
+ type SEOSection,
4
+ } from "@decocms/apps-website/components/Seo";
5
+ import { type BlogSeoDefaults, getBlogConfig } from "../../client";
6
+ import type { BlogPostListingPage } from "../../types";
7
+ import {
8
+ toBlogPosting,
9
+ toBreadcrumbList,
10
+ toOrganization,
11
+ withCanonicalBase,
12
+ } from "../../utils/jsonLD";
13
+
14
+ export type Props = {
15
+ /** @title Data Source */
16
+ jsonLD: BlogPostListingPage | null;
17
+ /** @title Title Override */
18
+ title?: string;
19
+ /** @title Description Override */
20
+ description?: string;
21
+ };
22
+
23
+ /** @title Blog Post listing */
24
+ export function loader(props: Props, req?: Request, seo?: BlogSeoDefaults) {
25
+ const rawSeo: BlogSeoDefaults = seo ?? getBlogConfig().seo ?? {};
26
+ const titleTemplate = typeof rawSeo.titleTemplate === "string" ? rawSeo.titleTemplate : "%s";
27
+ const descriptionTemplate =
28
+ typeof rawSeo.descriptionTemplate === "string" ? rawSeo.descriptionTemplate : "%s";
29
+ const { titleTemplate: _tt, descriptionTemplate: _dt, ...seoSiteProps } = rawSeo;
30
+
31
+ const { title: titleProp, description: descriptionProp, jsonLD } = props;
32
+
33
+ const title = renderTemplateString(titleTemplate, titleProp || jsonLD?.seo?.title || "");
34
+ const description = renderTemplateString(
35
+ descriptionTemplate,
36
+ descriptionProp || jsonLD?.seo?.description || "",
37
+ );
38
+
39
+ const { canonicalBaseUrl, publisher } = getBlogConfig();
40
+ const requestUrl = req?.url ?? canonicalBaseUrl ?? "http://localhost/";
41
+ // Configured canonicals may be relative; resolve against the request URL
42
+ const canonical = jsonLD?.seo?.canonical
43
+ ? withCanonicalBase(new URL(jsonLD.seo.canonical, requestUrl).href, canonicalBaseUrl)
44
+ : undefined;
45
+ const noIndexing = !jsonLD || jsonLD.seo?.noIndexing;
46
+
47
+ const url = canonical ?? withCanonicalBase(requestUrl, canonicalBaseUrl);
48
+ const jsonLDs = jsonLD
49
+ ? [
50
+ {
51
+ "@type": "Blog" as const,
52
+ ...(title ? { name: title } : {}),
53
+ ...(description ? { description } : {}),
54
+ url,
55
+ mainEntityOfPage: { "@type": "WebPage" as const, "@id": url },
56
+ ...(publisher?.name ? { publisher: toOrganization(publisher) } : {}),
57
+ blogPost: jsonLD.posts?.map((post) => toBlogPosting(post)) ?? [],
58
+ },
59
+ toBreadcrumbList(url, {
60
+ currentName: jsonLD.category?.name || title || undefined,
61
+ categories: jsonLD.categories ?? undefined,
62
+ }),
63
+ ]
64
+ : [];
65
+
66
+ return {
67
+ ...seoSiteProps,
68
+ title,
69
+ description,
70
+ canonical,
71
+ noIndexing,
72
+ jsonLDs,
73
+ };
74
+ }
75
+
76
+ function Section(props: Props): SEOSection {
77
+ return <SeoComponent {...props} />;
78
+ }
79
+
80
+ export default Section;
@@ -0,0 +1,96 @@
1
+ import { getSyncComponent } from "@decocms/blocks/cms/client";
2
+ import { getBlogConfig } from "../client";
3
+ import { CSS } from "../static/css";
4
+ import type { BlogPost, Section } from "../types";
5
+
6
+ export type Props = {
7
+ post: BlogPost | null;
8
+ };
9
+
10
+ const iframeStyle: React.CSSProperties = {
11
+ width: "100%",
12
+ border: "none",
13
+ height: "100vh",
14
+ };
15
+
16
+ /**
17
+ * Renders one CMS-authored section reference (`{ __resolveType, ...props }`).
18
+ *
19
+ * The Deno app used `renderSection` from `website/pages/Page.tsx`; here the
20
+ * equivalent is the section registry's synchronous component lookup. An
21
+ * unregistered `__resolveType` renders nothing rather than throwing — this is a
22
+ * preview surface, and a single unknown section should not blank the whole post.
23
+ */
24
+ function renderSection(section: Section, index: number) {
25
+ const resolveType: string | undefined = section?.__resolveType;
26
+ if (!resolveType) return null;
27
+
28
+ const Component =
29
+ getSyncComponent(resolveType) ?? getSyncComponent(resolveType.replace(/\.tsx?$/, ""));
30
+ if (!Component) return null;
31
+
32
+ const { __resolveType: _drop, ...props } = section;
33
+ return <Component key={`${resolveType}-${index}`} {...props} />;
34
+ }
35
+
36
+ /**
37
+ * CMS preview for a single blog post record.
38
+ *
39
+ * When the app is configured with a `pageSlug`/`categorySlug` route pattern the
40
+ * preview delegates to the real site route in an iframe, so what the editor sees
41
+ * is the actual page. Without one it falls back to rendering the record itself.
42
+ */
43
+ export default function Template({ post }: Props) {
44
+ if (!post) return null;
45
+
46
+ const { pageSlug, categorySlug } = getBlogConfig();
47
+
48
+ const {
49
+ title = "Title",
50
+ content = "Content",
51
+ excerpt = "Excerpt",
52
+ date,
53
+ image,
54
+ alt,
55
+ sections,
56
+ slug,
57
+ categories,
58
+ } = post;
59
+
60
+ const postCategorySlug = categories?.[0]?.slug ?? "";
61
+
62
+ if (pageSlug) {
63
+ const resolvedUrl = pageSlug.replace(":category", postCategorySlug).replace(":slug", slug);
64
+
65
+ return <iframe src={resolvedUrl} style={iframeStyle} title={title} />;
66
+ }
67
+
68
+ if (categorySlug) {
69
+ const resolvedUrl = categorySlug.replace(":category", postCategorySlug);
70
+
71
+ return <iframe src={resolvedUrl} style={iframeStyle} title={title} />;
72
+ }
73
+
74
+ return (
75
+ <>
76
+ <link href="/styles.css" rel="stylesheet" />
77
+ <style dangerouslySetInnerHTML={{ __html: CSS }} />
78
+ <div className="deco-post-preview">
79
+ <h1>{title}</h1>
80
+ <p className="text-xl">{excerpt}</p>
81
+ <p>
82
+ {date
83
+ ? new Date(`${date}T00:00:00`).toLocaleDateString("en-US", {
84
+ month: "long",
85
+ day: "numeric",
86
+ year: "numeric",
87
+ })
88
+ : ""}
89
+ </p>
90
+ {image && <img className="w-full rounded-2xl bg-cover" src={image} alt={alt ?? title} />}
91
+ <div dangerouslySetInnerHTML={{ __html: content as string }} />
92
+ <div className="content-sections">{sections?.map(renderSection)}</div>
93
+ </div>
94
+ </>
95
+ );
96
+ }