@decocms/apps-blog 8.0.0 → 8.1.0-next.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 (66) hide show
  1. package/README.md +21 -0
  2. package/package.json +14 -5
  3. package/src/README.md +69 -0
  4. package/src/__tests__/blogRecords.test.ts +270 -0
  5. package/src/__tests__/categoryTree.test.ts +209 -0
  6. package/src/__tests__/date.test.ts +134 -0
  7. package/src/__tests__/handlePosts.test.ts +256 -230
  8. package/src/__tests__/loaders.test.ts +198 -154
  9. package/src/__tests__/mod.test.ts +6 -6
  10. package/src/__tests__/publicationStatus.test.ts +176 -0
  11. package/src/__tests__/records.test.ts +68 -68
  12. package/src/__tests__/seoSections.test.ts +245 -0
  13. package/src/actions/submitRating.ts +36 -0
  14. package/src/actions/submitReview.ts +95 -0
  15. package/src/actions/submitView.ts +28 -0
  16. package/src/client.ts +70 -0
  17. package/src/core/blogRecords.ts +94 -0
  18. package/src/core/categoryTree.ts +210 -0
  19. package/src/core/handlePosts.ts +160 -64
  20. package/src/core/records.ts +147 -12
  21. package/src/index.ts +48 -14
  22. package/src/loaderMap.ts +28 -24
  23. package/src/loaders/BlogPostItem.ts +15 -5
  24. package/src/loaders/BlogPostPage.ts +61 -24
  25. package/src/loaders/BlogRelatedPosts.ts +45 -45
  26. package/src/loaders/BlogpostList.ts +93 -0
  27. package/src/loaders/BlogpostListing.ts +151 -85
  28. package/src/loaders/GetCategories.ts +51 -28
  29. package/src/loaders/extensions/BlogpostList/ratings.ts +36 -0
  30. package/src/loaders/extensions/BlogpostList/reviews.ts +36 -0
  31. package/src/loaders/extensions/BlogpostListing/ratings.ts +38 -0
  32. package/src/loaders/extensions/BlogpostListing/reviews.ts +38 -0
  33. package/src/loaders/extensions/BlogpostPage/ratings.ts +24 -0
  34. package/src/loaders/extensions/BlogpostPage/reviews.ts +31 -0
  35. package/src/loaders/extensions/types.ts +15 -0
  36. package/src/manifest.gen.ts +82 -14
  37. package/src/mod.ts +73 -20
  38. package/src/registry.ts +5 -5
  39. package/src/sections/Seo/SeoBlogPost.tsx +77 -0
  40. package/src/sections/Seo/SeoBlogPostListing.tsx +80 -0
  41. package/src/sections/Template.tsx +106 -0
  42. package/src/sections/blocks/BlockImage.tsx +42 -0
  43. package/src/sections/blocks/Callout.tsx +64 -0
  44. package/src/sections/blocks/CardGroup.tsx +60 -0
  45. package/src/sections/blocks/Checklist.tsx +58 -0
  46. package/src/sections/blocks/Code.tsx +29 -0
  47. package/src/sections/blocks/Comparison.tsx +77 -0
  48. package/src/sections/blocks/Cta.tsx +22 -0
  49. package/src/sections/blocks/Divider.tsx +3 -0
  50. package/src/sections/blocks/Heading.tsx +28 -0
  51. package/src/sections/blocks/List.tsx +39 -0
  52. package/src/sections/blocks/Paragraph.tsx +15 -0
  53. package/src/sections/blocks/Quote.tsx +32 -0
  54. package/src/sections/blocks/Stat.tsx +23 -0
  55. package/src/sections/blocks/StatGroup.tsx +38 -0
  56. package/src/sections/blocks/Steps.tsx +64 -0
  57. package/src/sections/blocks/Table.tsx +80 -0
  58. package/src/sections/blocks/Video.tsx +39 -0
  59. package/src/static/css.ts +178 -0
  60. package/src/types.ts +333 -77
  61. package/src/utils/blocksToSections.ts +175 -0
  62. package/src/utils/constants.ts +1 -0
  63. package/src/utils/date.ts +95 -0
  64. package/src/utils/hardSanitize.ts +106 -0
  65. package/src/utils/jsonLD.ts +118 -0
  66. package/src/utils/sanitizeHtml.ts +37 -0
@@ -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,83 @@ 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
+ * Categories can nest, so the route must be a catch-all for subcategories to
27
+ * resolve.
28
+ * @example /blog/:category*
29
+ */
30
+ categorySlug?: string;
31
+ /**
32
+ * @title Page Slug
33
+ * @description The slug of the BlogPostPage to embed. Use :category and :slug.
34
+ * @example /blog/:category*\/:slug
35
+ */
36
+ pageSlug?: string;
37
+ /**
38
+ * @title Canonical Base URL
39
+ * @description Overrides the origin of the url/mainEntityOfPage emitted in the JSON-LD by the SEO sections, which otherwise use the request host.
40
+ * @example https://www.mysite.com
41
+ */
42
+ canonicalBaseUrl?: string;
43
+ /**
44
+ * @title Publisher
45
+ * @description Emitted as the publisher of the blog posts in the JSON-LD.
46
+ */
47
+ publisher?: Publisher;
48
+ /**
49
+ * @title SEO defaults
50
+ * @description Title/description templates folded into the output of the blog's SEO sections.
51
+ */
52
+ seo?: BlogSeoDefaults;
25
53
  }
26
54
 
27
55
  export type BlogState = Props;
28
56
 
57
+ /**
58
+ * Action entrypoints, exposed under both key forms the resolver may use.
59
+ * Mirrors `@decocms/apps-resend`'s `handlers` map.
60
+ */
61
+ export const handlers: Record<string, AppHandler> = {
62
+ "blog/actions/submitRating": (props) => submitRating(props),
63
+ "blog/actions/submitRating.ts": (props) => submitRating(props),
64
+ "blog/actions/submitReview": (props) => submitReview(props),
65
+ "blog/actions/submitReview.ts": (props) => submitReview(props),
66
+ "blog/actions/submitView": (props) => submitView(props),
67
+ "blog/actions/submitView.ts": (props) => submitView(props),
68
+ };
69
+
29
70
  // -------------------------------------------------------------------------
30
71
  // Configure
31
72
  // -------------------------------------------------------------------------
32
73
 
33
74
  /**
34
75
  * Configure the Blog app from CMS block data.
35
- * Always returns an AppDefinition (no required fields).
76
+ * Always returns an AppDefinition — every field is optional, so a blog with no
77
+ * block configured still resolves and renders.
36
78
  */
37
79
  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,
80
+ block: any,
81
+ _resolveSecret: ResolveSecretFn,
41
82
  ): Promise<AppDefinition<BlogState>> {
42
- return {
43
- name: "blog",
44
- manifest,
45
- state: { pageSlug: _block?.pageSlug },
46
- };
83
+ const config: BlogConfig = {
84
+ categorySlug: block?.categorySlug,
85
+ pageSlug: block?.pageSlug,
86
+ canonicalBaseUrl: block?.canonicalBaseUrl,
87
+ publisher: block?.publisher,
88
+ seo: block?.seo,
89
+ };
90
+
91
+ // Bridge: the loaders and sections read this singleton in place of the Deno
92
+ // app's `ctx`, which has no equivalent here.
93
+ configureBlog(config);
94
+
95
+ return {
96
+ name: "blog",
97
+ manifest,
98
+ state: config,
99
+ };
47
100
  }
48
101
 
49
102
  /** Placeholder preview for CMS editor. */
@@ -51,5 +104,5 @@ export const preview = undefined;
51
104
 
52
105
  /** Default export for schema generation and Deno-style app bridges. */
53
106
  export default function Blog(state: Props) {
54
- return { state };
107
+ return { state };
55
108
  }
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,77 @@
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
+ // The ancestor chain, so every category segment of the canonical path
56
+ // resolves to its real name instead of a humanized slug.
57
+ categories: jsonLD.categories ?? jsonLD.post.categories,
58
+ }),
59
+ ]
60
+ : [];
61
+
62
+ return {
63
+ ...seoSiteProps,
64
+ title,
65
+ description,
66
+ image,
67
+ canonical,
68
+ noIndexing,
69
+ jsonLDs,
70
+ };
71
+ }
72
+
73
+ function Section(props: Props): SEOSection {
74
+ return <SeoComponent {...props} />;
75
+ }
76
+
77
+ 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,106 @@
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
+ mobileImage,
55
+ alt,
56
+ sections,
57
+ slug,
58
+ categories,
59
+ } = post;
60
+
61
+ // Only the leaf slug, not the ancestor path: resolving the chain needs
62
+ // `getRecordsByPath`, which pulls the server-only `@decocms/blocks/cms`
63
+ // entry into this client-reachable section. The preview iframe lands on the
64
+ // flat URL, which still renders.
65
+ const postCategorySlug = categories?.[0]?.slug ?? "";
66
+
67
+ if (pageSlug) {
68
+ const resolvedUrl = pageSlug.replace(/:category\*?/, postCategorySlug).replace(":slug", slug);
69
+
70
+ return <iframe src={resolvedUrl} style={iframeStyle} title={title} />;
71
+ }
72
+
73
+ if (categorySlug) {
74
+ const resolvedUrl = categorySlug.replace(/:category\*?/, postCategorySlug);
75
+
76
+ return <iframe src={resolvedUrl} style={iframeStyle} title={title} />;
77
+ }
78
+
79
+ return (
80
+ <>
81
+ <link href="/styles.css" rel="stylesheet" />
82
+ <style dangerouslySetInnerHTML={{ __html: CSS }} />
83
+ <div className="deco-post-preview">
84
+ <h1>{title}</h1>
85
+ <p className="text-xl">{excerpt}</p>
86
+ <p>
87
+ {date
88
+ ? new Date(`${date}T00:00:00`).toLocaleDateString("en-US", {
89
+ month: "long",
90
+ day: "numeric",
91
+ year: "numeric",
92
+ })
93
+ : ""}
94
+ </p>
95
+ {image && (
96
+ <picture>
97
+ {mobileImage && <source media="(max-width: 767px)" srcSet={mobileImage} />}
98
+ <img className="w-full rounded-2xl bg-cover" src={image} alt={alt ?? title} />
99
+ </picture>
100
+ )}
101
+ <div dangerouslySetInnerHTML={{ __html: content as string }} />
102
+ <div className="content-sections">{sections?.map(renderSection)}</div>
103
+ </div>
104
+ </>
105
+ );
106
+ }
@@ -0,0 +1,42 @@
1
+ export type Props = {
2
+ url: string;
3
+ /** @description Optional mobile-optimized image. Served below 768px. */
4
+ mobileUrl?: string;
5
+ alt?: string;
6
+ caption?: string;
7
+ size?: "full" | "normal";
8
+ /** @description Loads eagerly with high fetch priority. Use for above-the-fold images. */
9
+ highPriority?: boolean;
10
+ };
11
+
12
+ export default function BlockImage({
13
+ url,
14
+ mobileUrl,
15
+ alt,
16
+ caption,
17
+ size,
18
+ highPriority = false,
19
+ }: Props) {
20
+ const wrapperClass = size === "full" ? "my-8 -mx-[var(--gutter)]" : "my-8";
21
+
22
+ return (
23
+ <figure className={wrapperClass}>
24
+ <div className="overflow-hidden bg-alt">
25
+ <picture>
26
+ {mobileUrl && <source media="(max-width: 767px)" srcSet={mobileUrl} />}
27
+ <img
28
+ src={url}
29
+ alt={alt ?? ""}
30
+ {...(highPriority
31
+ ? { loading: "eager" as const, fetchPriority: "high" as const }
32
+ : { loading: "lazy" as const, decoding: "async" as const })}
33
+ className="w-full h-auto block"
34
+ />
35
+ </picture>
36
+ </div>
37
+ {caption && (
38
+ <figcaption className="text-sm text-tertiary text-center mt-3 italic">{caption}</figcaption>
39
+ )}
40
+ </figure>
41
+ );
42
+ }
@@ -0,0 +1,64 @@
1
+ import { sanitizeHtml } from "../../utils/sanitizeHtml";
2
+
3
+ const ICONS: Record<string, string> = {
4
+ info: "ℹ",
5
+ tip: "✓",
6
+ warning: "⚠",
7
+ product: "★",
8
+ };
9
+
10
+ type VariantStyle = { wrapper: string; header: string; icon: string };
11
+
12
+ const VARIANT: Record<string, VariantStyle> = {
13
+ info: {
14
+ wrapper: "border-blue-500 bg-blue-50",
15
+ header: "text-blue-600",
16
+ icon: "text-blue-500",
17
+ },
18
+ tip: {
19
+ wrapper: "border-green-500 bg-green-50",
20
+ header: "text-green-700",
21
+ icon: "text-green-600",
22
+ },
23
+ warning: {
24
+ wrapper: "border-amber-400 bg-amber-50",
25
+ header: "text-amber-700",
26
+ icon: "text-amber-500",
27
+ },
28
+ product: {
29
+ wrapper: "border-accent bg-accent/[0.06]",
30
+ header: "text-accent",
31
+ icon: "text-accent",
32
+ },
33
+ };
34
+
35
+ export type Props = {
36
+ title?: string;
37
+ body?: string;
38
+ variant?: "info" | "tip" | "warning" | "product";
39
+ };
40
+
41
+ export default function Callout({ title, body, variant = "info" }: Props) {
42
+ const v = VARIANT[variant] ?? VARIANT.info;
43
+
44
+ return (
45
+ <div className={`py-5 px-7 border-l-[3px] rounded-brand-sm my-6 ${v.wrapper}`}>
46
+ {(title || variant) && (
47
+ <div className={`flex items-center gap-2 mb-2.5 ${v.header}`}>
48
+ <span className={`text-sm font-bold leading-none ${v.icon}`} aria-hidden="true">
49
+ {ICONS[variant] ?? "ℹ"}
50
+ </span>
51
+ {title && (
52
+ <strong className="text-xs font-semibold tracking-caps uppercase">{title}</strong>
53
+ )}
54
+ </div>
55
+ )}
56
+ {body && (
57
+ <div
58
+ className="text-[1.0625rem] leading-normal [&>*+*]:mt-2 [&_a]:text-accent [&_a]:underline [&_strong]:font-semibold [&_strong]:text-base"
59
+ dangerouslySetInnerHTML={{ __html: sanitizeHtml(body) }}
60
+ />
61
+ )}
62
+ </div>
63
+ );
64
+ }