@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
@@ -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,361 @@
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;
39
+ url?: string;
13
40
  }
14
41
 
15
42
  export interface Category {
16
- name: string;
17
- slug: string;
43
+ name: string;
44
+ slug: string;
45
+ description?: string;
46
+ /**
47
+ * @title Parent category
48
+ * @description Slug of the parent category. Leave empty for a root category.
49
+ */
50
+ parentSlug?: string;
51
+ /**
52
+ * @title Sections
53
+ * @label hidden
54
+ * @changeable true
55
+ */
56
+ sections?: Section[];
18
57
  }
19
58
 
20
59
  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;
60
+ title: string;
61
+ excerpt: string;
62
+ /**
63
+ * @title Main image
64
+ */
65
+ image?: ImageWidget;
66
+ /**
67
+ * @title Alt text for the image
68
+ */
69
+ alt?: string;
70
+ /**
71
+ * @title Mobile image
72
+ * @description Optional mobile-optimized version of the main image. Served below 768px. Falls back to the main image when empty.
73
+ */
74
+ mobileImage?: ImageWidget;
75
+ /**
76
+ * @widget blog
77
+ * @collection authors
78
+ */
79
+ authors?: Author[];
80
+ /**
81
+ * @widget blog
82
+ * @collection categories
83
+ */
84
+ categories?: Category[];
85
+ /**
86
+ * @format date
87
+ */
88
+ date: string;
89
+ /**
90
+ * @title Modified date
91
+ * @format date
92
+ * @description Date of the last relevant content update. Emitted as dateModified in the JSON-LD.
93
+ */
94
+ dateModified?: string;
95
+ slug: string;
96
+ /**
97
+ * @title Status
98
+ * @description Publication status. Anything other than `published` is kept out of listings and never indexed. Posts with no status are treated as published.
99
+ */
100
+ status?: PostStatus;
101
+ /**
102
+ * @title Scheduled publication date
103
+ * @format datetime
104
+ * @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.
105
+ */
106
+ scheduledDatetime?: string;
107
+ /**
108
+ * @title Post Content
109
+ * @format rich-text
110
+ */
111
+ content?: string;
112
+ /**
113
+ * @title Sections
114
+ * @label hidden
115
+ * @changeable true
116
+ */
117
+ sections?: Section[];
118
+ /**
119
+ * @title Carousel in post content
120
+ * @description add a carousel in the middle of the post. Must be implemented in frontEnd
121
+ */
122
+ imageCarousel?: ImageCarousel;
123
+ /**
124
+ * @title SEO
125
+ */
126
+ seo?: Seo;
127
+ /**
128
+ * @title ReadTime in minutes
129
+ */
130
+ readTime?: number;
131
+ /**
132
+ * @title Extra Props
133
+ */
134
+ extraProps?: ExtraProps[];
135
+ /** @hide true */
136
+ aggregateRating?: AggregateRating;
137
+ /** @hide true */
138
+ review?: Review[];
139
+ /** @hide true */
140
+ contentRating?: Rating[];
141
+ /** @hide true */
142
+ interactionStatistic?: InteractionCounter;
143
+ id?: string;
70
144
  }
71
145
 
146
+ /**
147
+ * Publication status of a post. `published` (or an absent value, for legacy
148
+ * posts) renders on the live site; `scheduled` renders once its
149
+ * `scheduledDatetime` has passed; every other value keeps the post out of
150
+ * listings and out of the index.
151
+ *
152
+ * `generating` and `awaiting_review` are written by the autonomous-blog agent
153
+ * while a post is still being produced, which is why the checks below are
154
+ * allowlists: a status this app does not recognize is a post the CMS does not
155
+ * consider ready, so it must not leak into a listing.
156
+ */
157
+ export type PostStatus =
158
+ | "draft"
159
+ | "published"
160
+ | "scheduled"
161
+ | "archived"
162
+ | "generating"
163
+ | "awaiting_review";
164
+
165
+ /**
166
+ * A post is live when it has no status at all or is explicitly `published`.
167
+ *
168
+ * The absent case is load-bearing: `status` was added long after the first
169
+ * posts were written, so every existing record is missing it. Requiring an
170
+ * explicit `published` would empty every blog in production the moment a site
171
+ * bumps this app.
172
+ *
173
+ * Takes a plain `string` so it can also be applied to a raw CMS record, where
174
+ * the value is only a `PostStatus` by convention.
175
+ */
176
+ export const isPublishedStatus = (status?: string): boolean => !status || status === "published";
177
+
178
+ /**
179
+ * Whether a post is live *right now* — the read-time half of scheduling.
180
+ *
181
+ * A scheduled post is merged to production ahead of its go-live instant, so
182
+ * nothing rewrites the record when that instant arrives: this comparison is
183
+ * what flips it, on whichever request first evaluates it after the fact.
184
+ *
185
+ * Like `isPublishedStatus`, this is an allowlist and stays one deliberately:
186
+ * only an absent/empty status, an explicit `published`, or a `scheduled` post
187
+ * whose instant has arrived is live. That makes every status added later — by
188
+ * the CMS or by an agent — fail closed on an app version that predates it,
189
+ * hiding the post instead of leaking a half-written one. A `status !== "draft"`
190
+ * blacklist would lose that property permanently.
191
+ */
192
+ export const isLivePost = (
193
+ post: { status?: string; scheduledDatetime?: string },
194
+ now: number = Date.now(),
195
+ ): boolean => {
196
+ if (isPublishedStatus(post.status)) {
197
+ return true;
198
+ }
199
+ if (post.status !== "scheduled") {
200
+ return false;
201
+ }
202
+ const goLive = post.scheduledDatetime ? scheduledTime(post.scheduledDatetime) : null;
203
+ // A missing or unreadable instant is rejected rather than compared: this is
204
+ // the fail-closed guarantee, not a redundant null check. `scheduledTime`
205
+ // returns null (not 0) for a bad value precisely so that a real instant which
206
+ // happens to be the epoch is still honoured here.
207
+ return goLive !== null && goLive <= now;
208
+ };
209
+
72
210
  export interface ExtraProps {
73
- key: string;
74
- value: string;
211
+ key: string;
212
+ value: string;
75
213
  }
76
214
 
77
215
  export interface Seo {
78
- title?: string;
79
- description?: string;
80
- image?: ImageWidget;
81
- canonical?: string;
82
- noIndexing?: boolean;
216
+ title?: string;
217
+ description?: string;
218
+ image?: ImageWidget;
219
+ canonical?: string;
220
+ noIndexing?: boolean;
221
+ }
222
+
223
+ /** @titleBy name */
224
+ export interface Publisher {
225
+ name: string;
226
+ /** @title Logo */
227
+ logo?: ImageWidget;
228
+ url?: string;
83
229
  }
84
230
 
85
231
  export interface BlogPostPage {
86
- "@type": "BlogPostPage";
87
- post: BlogPost;
88
- seo?: Seo | null;
232
+ "@type": "BlogPostPage";
233
+ post: BlogPost;
234
+ /**
235
+ * @title Category path
236
+ * @description Ancestor chain of the post's primary category, root first.
237
+ */
238
+ categories?: Category[] | null;
239
+ seo?: Seo | null;
89
240
  }
90
241
 
91
- export type SortBy = "date_desc" | "date_asc" | "title_asc" | "title_desc";
242
+ export type SortBy =
243
+ | "date_desc"
244
+ | "date_asc"
245
+ | "title_asc"
246
+ | "title_desc"
247
+ | "view_asc"
248
+ | "view_desc";
92
249
 
93
- export interface PageInfo {
94
- nextPage?: string;
95
- previousPage?: string;
96
- currentPage: number;
97
- records?: number;
98
- recordPerPage?: number;
250
+ export interface BlogPostListingPage {
251
+ posts: BlogPost[];
252
+ /** @title Active category */
253
+ category?: Category | null;
254
+ /** @title Categories */
255
+ categories?: Category[] | null;
256
+ /**
257
+ * @title Category path
258
+ * @description Ancestor chain of the active category, root first.
259
+ */
260
+ categoryPath?: Category[] | null;
261
+ pageInfo: PageInfo;
262
+ seo: Seo;
99
263
  }
100
264
 
101
- export interface BlogPostListingPage {
102
- posts: BlogPost[];
103
- pageInfo: PageInfo;
104
- seo: Seo;
265
+ export interface ImageCarousel {
266
+ banners?: Banner[];
267
+ description?: string;
268
+ }
269
+
270
+ export interface Review {
271
+ "@type": "Review";
272
+ id?: string;
273
+ /** Author of the review */
274
+ author?: Person;
275
+ /** The date that the review was published, in ISO 8601 date format.*/
276
+ datePublished?: string;
277
+ /** The date that the review was modified, in ISO 8601 date format.*/
278
+ dateModified?: string;
279
+ /** The item that is being reviewed/rated. */
280
+ itemReviewed?: string;
281
+ /** Emphasis part of the review */
282
+ reviewHeadline?: string;
283
+ /** The actual body of the review. */
284
+ reviewBody?: string;
285
+ /** Review status */
286
+ additionalType?: string;
287
+ /** Anonymous comment. Not in Schema.org */
288
+ isAnonymous?: boolean;
289
+ }
290
+
291
+ export interface Rating {
292
+ "@type": "Rating";
293
+ id?: string;
294
+ /** The author of this content or rating. */
295
+ author?: Person;
296
+ /** The item that is being reviewed/rated. */
297
+ itemReviewed?: string;
298
+ /** The highest value allowed in this rating system. */
299
+ bestRating?: number;
300
+ /** The lowest value allowed in this rating system. */
301
+ worstRating?: number;
302
+ /** A short explanation providing background context and other information that led to the conclusion expressed in the rating. */
303
+ ratingValue?: number;
304
+ /** Review status */
305
+ additionalType?: string;
306
+ }
307
+
308
+ export interface AggregateRating {
309
+ "@type": "AggregateRating";
310
+ /** The count of total number of ratings. */
311
+ ratingCount?: number;
312
+ /** The count of total number of reviews. */
313
+ reviewCount?: number;
314
+ /** The rating for the content. */
315
+ ratingValue?: number;
316
+ /** The highest value allowed in this rating system. */
317
+ bestRating?: number;
318
+ /** The lowest value allowed in this rating system. */
319
+ worstRating?: number;
320
+ }
321
+
322
+ export interface InteractionCounter extends Omit<Thing, "@type"> {
323
+ "@type": "InteractionCounter";
324
+ /** The number of interactions or views */
325
+ userInteractionCount?: number;
326
+ }
327
+
328
+ export interface ViewFromDatabase {
329
+ id: string;
330
+ userInteractionCount?: number;
331
+ }
332
+
333
+ export interface Ignore {
334
+ /**
335
+ * @title Active
336
+ */
337
+ active?: boolean;
338
+ /**
339
+ * @title When additionalType is marked with:
340
+ */
341
+ markedAs?: string[];
342
+ }
343
+
344
+ export interface BannerItem {
345
+ image?: ImageWidget;
346
+ width?: number;
347
+ height?: number;
348
+ }
349
+
350
+ export interface Banner {
351
+ /** @description desktop otimized image */
352
+ desktop: BannerItem;
353
+ /** @description mobile otimized image */
354
+ mobile: BannerItem;
355
+ /** @description Image's alt text */
356
+ alt: string;
357
+ action?: {
358
+ /** @description when user clicks on the image, go to this link */
359
+ href: string;
360
+ };
105
361
  }