@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.
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,39 @@
1
+ export type Props = {
2
+ url: string;
3
+ caption?: string;
4
+ };
5
+
6
+ function resolveEmbedSrc(raw: string): string | null {
7
+ const ytMatch = raw.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
8
+ if (ytMatch) return `https://www.youtube.com/embed/${ytMatch[1]}`;
9
+
10
+ const vimeoMatch = raw.match(/vimeo\.com\/(\d+)/);
11
+ if (vimeoMatch) return `https://player.vimeo.com/video/${vimeoMatch[1]}`;
12
+
13
+ // Reject anything that didn't match a trusted provider.
14
+ return null;
15
+ }
16
+
17
+ export default function Video({ url, caption }: Props) {
18
+ const src = resolveEmbedSrc(url ?? "");
19
+
20
+ if (!src) return null;
21
+
22
+ return (
23
+ <figure className="my-8">
24
+ <div className="relative aspect-video overflow-hidden bg-alt">
25
+ <iframe
26
+ src={src}
27
+ className="absolute inset-0 w-full h-full border-0"
28
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
29
+ allowFullScreen
30
+ loading="lazy"
31
+ title={caption ?? "Video"}
32
+ />
33
+ </div>
34
+ {caption && (
35
+ <figcaption className="text-sm text-tertiary text-center mt-3 italic">{caption}</figcaption>
36
+ )}
37
+ </figure>
38
+ );
39
+ }
@@ -0,0 +1,178 @@
1
+ export const CSS = `
2
+ .deco-post-preview{
3
+ width: 100%;
4
+ margin-left: auto;
5
+ margin-right: auto;
6
+ }
7
+
8
+ @media (min-width: 640px){
9
+ .deco-post-preview{
10
+ max-width: 640px
11
+ }
12
+ }
13
+
14
+ @media (min-width: 768px){
15
+ .deco-post-preview{
16
+ max-width: 768px
17
+ }
18
+ }
19
+
20
+ .deco-post-preview {
21
+ font-family: 'Albert sans', sans-serif;
22
+ color: #66736C;
23
+ }
24
+
25
+ .deco-post-preview p {
26
+ line-height: 1.75rem;
27
+ margin-bottom: 1rem;
28
+ }
29
+
30
+ .deco-post-preview h1,
31
+ .deco-post-preview h2,
32
+ .deco-post-preview h3,
33
+ .deco-post-preview h4,
34
+ .deco-post-preview h5,
35
+ .deco-post-preview h6 {
36
+ color: #161616;
37
+ }
38
+
39
+ .deco-post-preview h1 {
40
+ font-size: 2.25rem;
41
+ margin-top: 2.5rem;
42
+ margin-bottom: 1rem;
43
+ line-height: 2.5rem;
44
+ }
45
+
46
+ .deco-post-preview h2 {
47
+ font-size: 1.5rem;
48
+ margin-top: 2rem;
49
+ margin-bottom: 0.75rem;
50
+ line-height: 2rem;
51
+ }
52
+
53
+ .deco-post-preview h3 {
54
+ font-size: 1.25rem;
55
+ margin-top: 1.75rem;
56
+ margin-bottom: 0.5rem;
57
+ line-height: 1.75rem;
58
+ }
59
+
60
+ .deco-post-preview h4 {
61
+ font-size: 1rem;
62
+ margin-top: 1.5rem;
63
+ margin-bottom: 0.5rem;
64
+ line-height: 1.5rem;
65
+ }
66
+
67
+ .deco-post-preview h5 {
68
+ font-size: 0.875rem;
69
+ margin-top: 1.25rem;
70
+ margin-bottom: 0.25rem;
71
+ line-height: 1.25rem;
72
+ }
73
+
74
+ .deco-post-preview h6 {
75
+ font-size: 0.75rem;
76
+ margin-top: 1rem;
77
+ margin-bottom: 0.25rem;
78
+ line-height: 1rem;
79
+ }
80
+
81
+ .deco-post-preview time,
82
+ .deco-post-preview address {
83
+ font-size: 0.875rem;
84
+ color: #555;
85
+ }
86
+
87
+ .deco-post-preview a[rel="tag"] {
88
+ background-color: #f0f0f0;
89
+ color: #333;
90
+ padding: 0.25rem 0.5rem;
91
+ border-radius: 0.25rem;
92
+ font-size: 0.875rem;
93
+ }
94
+
95
+ .deco-post-preview a {
96
+ color: #007bff;
97
+ text-decoration: none;
98
+ }
99
+
100
+ .deco-post-preview a:hover {
101
+ color: #0056b3;
102
+ }
103
+
104
+ .deco-post-preview pre {
105
+ background-color: #f4f4f4;
106
+ border: 1px solid #ddd;
107
+ padding: 1rem;
108
+ border-radius: 0.25rem;
109
+ overflow-x: auto;
110
+ }
111
+
112
+ .deco-post-preview code {
113
+ font-family: 'Courier New', monospace;
114
+ background-color: #f4f4f4;
115
+ padding: 0.25rem 0.5rem;
116
+ border-radius: 0.25rem;
117
+ font-size: 0.875rem;
118
+ }
119
+
120
+ .deco-post-preview ul,
121
+ .deco-post-preview ol {
122
+ margin-left: 1.5rem;
123
+ margin-bottom: 1rem;
124
+ }
125
+
126
+ .decopost-preview ul {
127
+ list-style-type: disc;
128
+ }
129
+
130
+ .deco-post-preview ol {
131
+ list-style-type: decimal;
132
+ }
133
+
134
+ .deco-post-preview li {
135
+ margin-bottom: 0.25rem;
136
+ }
137
+
138
+ .deco-post-preview blockquote {
139
+ font-style: italic;
140
+ overflow: hidden;
141
+ padding-left: 1rem;
142
+ color: #555;
143
+ border-left: 4px solid #ddd;
144
+ margin: 0 0 1rem;
145
+ line-height: 1.75rem;
146
+ }
147
+
148
+ .deco-post-preview img {
149
+ max-width: 100%;
150
+ height: auto;
151
+ margin-bottom: 1rem;
152
+ }
153
+
154
+ .deco-post-preview table {
155
+ border-collapse: collapse;
156
+ width: 100%;
157
+ margin-bottom: 1rem;
158
+ }
159
+
160
+ .deco-post-preview th,
161
+ .deco-post-preview td {
162
+ text-align: left;
163
+ padding: 0.5rem;
164
+ border: 1px solid #ddd;
165
+ }
166
+
167
+ .deco-post-preview th {
168
+ background-color: #f4f4f4;
169
+ }
170
+
171
+ .deco-post-preview hr {
172
+ border: 0;
173
+ height: 1px;
174
+ background: #e1e1e1;
175
+ margin-bottom: 1rem;
176
+ }
177
+
178
+ `;
package/src/types.ts CHANGED
@@ -1,105 +1,340 @@
1
+ import type { PageInfo, Person, Thing } from "@decocms/apps-commerce/types";
1
2
  import type { ImageWidget } from "@decocms/apps-website/types";
3
+ import { scheduledTime } from "./utils/date";
4
+
5
+ export type { PageInfo };
6
+
7
+ /**
8
+ * Opaque "pick any section" reference, as authored in the decofile:
9
+ * `{ "__resolveType": "site/sections/Foo.tsx", ...props }`.
10
+ *
11
+ * Deliberately `any`, and deliberately named `Section`. `@decocms/blocks-cli`'s
12
+ * schema generator only emits the admin's section-picker widget for a prop whose
13
+ * type is *named* `Section` **and** resolves to `any`/`unknown`
14
+ * (`generate-schema.ts`, `isOpaqueSectionType`) — a concrete object shape is
15
+ * treated as user data and rendered as an inline editable form instead. So this
16
+ * is not laziness: narrowing it would silently downgrade the editor experience.
17
+ *
18
+ * Note this is NOT `Section` from `@decocms/blocks/types`, which is the
19
+ * *resolved* `{ Component, props }` pair produced after block resolution.
20
+ */
21
+ export type Section = any;
2
22
 
3
23
  /**
4
24
  * @titleBy name
5
25
  * @widget author
6
26
  */
7
27
  export interface Author {
8
- name: string;
9
- email: string;
10
- avatar?: ImageWidget;
11
- jobTitle?: string;
12
- company?: string;
28
+ name: string;
29
+ email: string;
30
+ /**
31
+ * @title Type
32
+ * @description Whether the author is a person or an organization. Emitted as the author @type in the JSON-LD. Defaults to Person.
33
+ * @default Person
34
+ */
35
+ type?: "Person" | "Organization";
36
+ avatar?: ImageWidget;
37
+ jobTitle?: string;
38
+ company?: string;
13
39
  }
14
40
 
15
41
  export interface Category {
16
- name: string;
17
- slug: string;
42
+ name: string;
43
+ slug: string;
44
+ description?: string;
45
+ /**
46
+ * @title Sections
47
+ * @label hidden
48
+ * @changeable true
49
+ */
50
+ sections?: Section[];
18
51
  }
19
52
 
20
53
  export interface BlogPost {
21
- title: string;
22
- excerpt: string;
23
- /**
24
- * @title Main image
25
- */
26
- image?: ImageWidget;
27
- /**
28
- * @title Alt text for the image
29
- */
30
- alt?: string;
31
- /**
32
- * @widget blog
33
- * @collection authors
34
- */
35
- authors?: Author[];
36
- /**
37
- * @widget blog
38
- * @collection categories
39
- */
40
- categories?: Category[];
41
- /**
42
- * @format date
43
- */
44
- date: string;
45
- slug: string;
46
- /**
47
- * @title Post Content
48
- * @format rich-text
49
- */
50
- content?: string;
51
- /**
52
- * @title Sections
53
- * @label hidden
54
- * @changeable true
55
- */
56
- sections?: unknown[];
57
- /**
58
- * @title SEO
59
- */
60
- seo?: Seo;
61
- /**
62
- * @title ReadTime in minutes
63
- */
64
- readTime?: number;
65
- /**
66
- * @title Extra Props
67
- */
68
- extraProps?: ExtraProps[];
69
- id?: string;
54
+ title: string;
55
+ excerpt: string;
56
+ /**
57
+ * @title Main image
58
+ */
59
+ image?: ImageWidget;
60
+ /**
61
+ * @title Alt text for the image
62
+ */
63
+ alt?: string;
64
+ /**
65
+ * @widget blog
66
+ * @collection authors
67
+ */
68
+ authors?: Author[];
69
+ /**
70
+ * @widget blog
71
+ * @collection categories
72
+ */
73
+ categories?: Category[];
74
+ /**
75
+ * @format date
76
+ */
77
+ date: string;
78
+ /**
79
+ * @title Modified date
80
+ * @format date
81
+ * @description Date of the last relevant content update. Emitted as dateModified in the JSON-LD.
82
+ */
83
+ dateModified?: string;
84
+ slug: string;
85
+ /**
86
+ * @title Status
87
+ * @description Publication status. Anything other than `published` is kept out of listings and never indexed. Posts with no status are treated as published.
88
+ */
89
+ status?: PostStatus;
90
+ /**
91
+ * @title Scheduled publication date
92
+ * @format datetime
93
+ * @description Instant this post goes live, honoured only while status is `scheduled`. Deliberately separate from the editorial date shown and sorted on: the two may diverge.
94
+ */
95
+ scheduledDatetime?: string;
96
+ /**
97
+ * @title Post Content
98
+ * @format rich-text
99
+ */
100
+ content?: string;
101
+ /**
102
+ * @title Sections
103
+ * @label hidden
104
+ * @changeable true
105
+ */
106
+ sections?: Section[];
107
+ /**
108
+ * @title Carousel in post content
109
+ * @description add a carousel in the middle of the post. Must be implemented in frontEnd
110
+ */
111
+ imageCarousel?: ImageCarousel;
112
+ /**
113
+ * @title SEO
114
+ */
115
+ seo?: Seo;
116
+ /**
117
+ * @title ReadTime in minutes
118
+ */
119
+ readTime?: number;
120
+ /**
121
+ * @title Extra Props
122
+ */
123
+ extraProps?: ExtraProps[];
124
+ /** @hide true */
125
+ aggregateRating?: AggregateRating;
126
+ /** @hide true */
127
+ review?: Review[];
128
+ /** @hide true */
129
+ contentRating?: Rating[];
130
+ /** @hide true */
131
+ interactionStatistic?: InteractionCounter;
132
+ id?: string;
70
133
  }
71
134
 
135
+ /**
136
+ * Publication status of a post. `published` (or an absent value, for legacy
137
+ * posts) renders on the live site; `scheduled` renders once its
138
+ * `scheduledDatetime` has passed; every other value keeps the post out of
139
+ * listings and out of the index.
140
+ *
141
+ * `generating` and `awaiting_review` are written by the autonomous-blog agent
142
+ * while a post is still being produced, which is why the checks below are
143
+ * allowlists: a status this app does not recognize is a post the CMS does not
144
+ * consider ready, so it must not leak into a listing.
145
+ */
146
+ export type PostStatus =
147
+ | "draft"
148
+ | "published"
149
+ | "scheduled"
150
+ | "archived"
151
+ | "generating"
152
+ | "awaiting_review";
153
+
154
+ /**
155
+ * A post is live when it has no status at all or is explicitly `published`.
156
+ *
157
+ * The absent case is load-bearing: `status` was added long after the first
158
+ * posts were written, so every existing record is missing it. Requiring an
159
+ * explicit `published` would empty every blog in production the moment a site
160
+ * bumps this app.
161
+ *
162
+ * Takes a plain `string` so it can also be applied to a raw CMS record, where
163
+ * the value is only a `PostStatus` by convention.
164
+ */
165
+ export const isPublishedStatus = (status?: string): boolean => !status || status === "published";
166
+
167
+ /**
168
+ * Whether a post is live *right now* — the read-time half of scheduling.
169
+ *
170
+ * A scheduled post is merged to production ahead of its go-live instant, so
171
+ * nothing rewrites the record when that instant arrives: this comparison is
172
+ * what flips it, on whichever request first evaluates it after the fact.
173
+ *
174
+ * Like `isPublishedStatus`, this is an allowlist and stays one deliberately:
175
+ * only an absent/empty status, an explicit `published`, or a `scheduled` post
176
+ * whose instant has arrived is live. That makes every status added later — by
177
+ * the CMS or by an agent — fail closed on an app version that predates it,
178
+ * hiding the post instead of leaking a half-written one. A `status !== "draft"`
179
+ * blacklist would lose that property permanently.
180
+ */
181
+ export const isLivePost = (
182
+ post: { status?: string; scheduledDatetime?: string },
183
+ now: number = Date.now(),
184
+ ): boolean => {
185
+ if (isPublishedStatus(post.status)) {
186
+ return true;
187
+ }
188
+ if (post.status !== "scheduled") {
189
+ return false;
190
+ }
191
+ const goLive = post.scheduledDatetime ? scheduledTime(post.scheduledDatetime) : null;
192
+ // A missing or unreadable instant is rejected rather than compared: this is
193
+ // the fail-closed guarantee, not a redundant null check. `scheduledTime`
194
+ // returns null (not 0) for a bad value precisely so that a real instant which
195
+ // happens to be the epoch is still honoured here.
196
+ return goLive !== null && goLive <= now;
197
+ };
198
+
72
199
  export interface ExtraProps {
73
- key: string;
74
- value: string;
200
+ key: string;
201
+ value: string;
75
202
  }
76
203
 
77
204
  export interface Seo {
78
- title?: string;
79
- description?: string;
80
- image?: ImageWidget;
81
- canonical?: string;
82
- noIndexing?: boolean;
205
+ title?: string;
206
+ description?: string;
207
+ image?: ImageWidget;
208
+ canonical?: string;
209
+ noIndexing?: boolean;
210
+ }
211
+
212
+ /** @titleBy name */
213
+ export interface Publisher {
214
+ name: string;
215
+ /** @title Logo */
216
+ logo?: ImageWidget;
217
+ url?: string;
83
218
  }
84
219
 
85
220
  export interface BlogPostPage {
86
- "@type": "BlogPostPage";
87
- post: BlogPost;
88
- seo?: Seo | null;
221
+ "@type": "BlogPostPage";
222
+ post: BlogPost;
223
+ seo?: Seo | null;
89
224
  }
90
225
 
91
- export type SortBy = "date_desc" | "date_asc" | "title_asc" | "title_desc";
226
+ export type SortBy =
227
+ | "date_desc"
228
+ | "date_asc"
229
+ | "title_asc"
230
+ | "title_desc"
231
+ | "view_asc"
232
+ | "view_desc";
92
233
 
93
- export interface PageInfo {
94
- nextPage?: string;
95
- previousPage?: string;
96
- currentPage: number;
97
- records?: number;
98
- recordPerPage?: number;
234
+ export interface BlogPostListingPage {
235
+ posts: BlogPost[];
236
+ /** @title Active category */
237
+ category?: Category | null;
238
+ /** @title Categories */
239
+ categories?: Category[] | null;
240
+ pageInfo: PageInfo;
241
+ seo: Seo;
99
242
  }
100
243
 
101
- export interface BlogPostListingPage {
102
- posts: BlogPost[];
103
- pageInfo: PageInfo;
104
- seo: Seo;
244
+ export interface ImageCarousel {
245
+ banners?: Banner[];
246
+ description?: string;
247
+ }
248
+
249
+ export interface Review {
250
+ "@type": "Review";
251
+ id?: string;
252
+ /** Author of the review */
253
+ author?: Person;
254
+ /** The date that the review was published, in ISO 8601 date format.*/
255
+ datePublished?: string;
256
+ /** The date that the review was modified, in ISO 8601 date format.*/
257
+ dateModified?: string;
258
+ /** The item that is being reviewed/rated. */
259
+ itemReviewed?: string;
260
+ /** Emphasis part of the review */
261
+ reviewHeadline?: string;
262
+ /** The actual body of the review. */
263
+ reviewBody?: string;
264
+ /** Review status */
265
+ additionalType?: string;
266
+ /** Anonymous comment. Not in Schema.org */
267
+ isAnonymous?: boolean;
268
+ }
269
+
270
+ export interface Rating {
271
+ "@type": "Rating";
272
+ id?: string;
273
+ /** The author of this content or rating. */
274
+ author?: Person;
275
+ /** The item that is being reviewed/rated. */
276
+ itemReviewed?: string;
277
+ /** The highest value allowed in this rating system. */
278
+ bestRating?: number;
279
+ /** The lowest value allowed in this rating system. */
280
+ worstRating?: number;
281
+ /** A short explanation providing background context and other information that led to the conclusion expressed in the rating. */
282
+ ratingValue?: number;
283
+ /** Review status */
284
+ additionalType?: string;
285
+ }
286
+
287
+ export interface AggregateRating {
288
+ "@type": "AggregateRating";
289
+ /** The count of total number of ratings. */
290
+ ratingCount?: number;
291
+ /** The count of total number of reviews. */
292
+ reviewCount?: number;
293
+ /** The rating for the content. */
294
+ ratingValue?: number;
295
+ /** The highest value allowed in this rating system. */
296
+ bestRating?: number;
297
+ /** The lowest value allowed in this rating system. */
298
+ worstRating?: number;
299
+ }
300
+
301
+ export interface InteractionCounter extends Omit<Thing, "@type"> {
302
+ "@type": "InteractionCounter";
303
+ /** The number of interactions or views */
304
+ userInteractionCount?: number;
305
+ }
306
+
307
+ export interface ViewFromDatabase {
308
+ id: string;
309
+ userInteractionCount?: number;
310
+ }
311
+
312
+ export interface Ignore {
313
+ /**
314
+ * @title Active
315
+ */
316
+ active?: boolean;
317
+ /**
318
+ * @title When additionalType is marked with:
319
+ */
320
+ markedAs?: string[];
321
+ }
322
+
323
+ export interface BannerItem {
324
+ image?: ImageWidget;
325
+ width?: number;
326
+ height?: number;
327
+ }
328
+
329
+ export interface Banner {
330
+ /** @description desktop otimized image */
331
+ desktop: BannerItem;
332
+ /** @description mobile otimized image */
333
+ mobile: BannerItem;
334
+ /** @description Image's alt text */
335
+ alt: string;
336
+ action?: {
337
+ /** @description when user clicks on the image, go to this link */
338
+ href: string;
339
+ };
105
340
  }