@decocms/apps-blog 7.52.1 → 7.54.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.
- package/package.json +13 -4
- package/src/README.md +69 -0
- package/src/__tests__/blogRecords.test.ts +270 -0
- package/src/__tests__/date.test.ts +134 -0
- package/src/__tests__/handlePosts.test.ts +230 -230
- package/src/__tests__/loaders.test.ts +154 -154
- package/src/__tests__/mod.test.ts +6 -6
- package/src/__tests__/publicationStatus.test.ts +176 -0
- package/src/__tests__/records.test.ts +68 -68
- package/src/__tests__/seoSections.test.ts +245 -0
- package/src/actions/submitRating.ts +36 -0
- package/src/actions/submitReview.ts +95 -0
- package/src/actions/submitView.ts +28 -0
- package/src/client.ts +69 -0
- package/src/core/blogRecords.ts +94 -0
- package/src/core/handlePosts.ts +154 -63
- package/src/core/records.ts +147 -12
- package/src/index.ts +48 -14
- package/src/loaderMap.ts +28 -24
- package/src/loaders/BlogPostItem.ts +15 -5
- package/src/loaders/BlogPostPage.ts +25 -20
- package/src/loaders/BlogRelatedPosts.ts +45 -45
- package/src/loaders/BlogpostList.ts +73 -0
- package/src/loaders/BlogpostListing.ts +114 -77
- package/src/loaders/GetCategories.ts +42 -28
- package/src/loaders/extensions/BlogpostList/ratings.ts +36 -0
- package/src/loaders/extensions/BlogpostList/reviews.ts +36 -0
- package/src/loaders/extensions/BlogpostListing/ratings.ts +38 -0
- package/src/loaders/extensions/BlogpostListing/reviews.ts +38 -0
- package/src/loaders/extensions/BlogpostPage/ratings.ts +24 -0
- package/src/loaders/extensions/BlogpostPage/reviews.ts +31 -0
- package/src/loaders/extensions/types.ts +15 -0
- package/src/manifest.gen.ts +82 -14
- package/src/mod.ts +71 -20
- package/src/registry.ts +5 -5
- package/src/sections/Seo/SeoBlogPost.tsx +75 -0
- package/src/sections/Seo/SeoBlogPostListing.tsx +80 -0
- package/src/sections/Template.tsx +96 -0
- package/src/sections/blocks/BlockImage.tsx +21 -0
- package/src/sections/blocks/Callout.tsx +64 -0
- package/src/sections/blocks/CardGroup.tsx +60 -0
- package/src/sections/blocks/Checklist.tsx +58 -0
- package/src/sections/blocks/Code.tsx +29 -0
- package/src/sections/blocks/Comparison.tsx +77 -0
- package/src/sections/blocks/Cta.tsx +22 -0
- package/src/sections/blocks/Divider.tsx +3 -0
- package/src/sections/blocks/Heading.tsx +28 -0
- package/src/sections/blocks/List.tsx +39 -0
- package/src/sections/blocks/Paragraph.tsx +15 -0
- package/src/sections/blocks/Quote.tsx +32 -0
- package/src/sections/blocks/Stat.tsx +23 -0
- package/src/sections/blocks/StatGroup.tsx +38 -0
- package/src/sections/blocks/Steps.tsx +64 -0
- package/src/sections/blocks/Table.tsx +80 -0
- package/src/sections/blocks/Video.tsx +39 -0
- package/src/static/css.ts +178 -0
- package/src/types.ts +312 -77
- package/src/utils/blocksToSections.ts +170 -0
- package/src/utils/constants.ts +1 -0
- package/src/utils/date.ts +95 -0
- package/src/utils/hardSanitize.ts +106 -0
- package/src/utils/jsonLD.ts +115 -0
- 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>;
|
package/src/manifest.gen.ts
CHANGED
|
@@ -1,26 +1,94 @@
|
|
|
1
|
-
// AUTO-GENERATED
|
|
2
|
-
//
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
_resolveSecret: ResolveSecretFn,
|
|
78
|
+
block: any,
|
|
79
|
+
_resolveSecret: ResolveSecretFn,
|
|
41
80
|
): Promise<AppDefinition<BlogState>> {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
}
|