@soloworks/smking-next 0.21.0 → 0.21.2
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/CHANGELOG.md +37 -0
- package/package.json +1 -1
- package/src/cms-blocks.ts +470 -0
- package/src/index.ts +21 -0
- package/src/lib/webhook-route.ts +53 -0
- package/src/types.ts +31 -52
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# @soloworks/smking-next
|
|
2
2
|
|
|
3
|
+
## 0.21.2 — 2026-07-02
|
|
4
|
+
|
|
5
|
+
**CMS taxonomy page types caught up with the SaaS catalog (types only — no runtime change).**
|
|
6
|
+
|
|
7
|
+
The CMS page model now includes the `taxonomy` content type used by protected
|
|
8
|
+
category and tag index pages. This keeps the published SDK type surface aligned
|
|
9
|
+
with the SaaS block contract after the taxonomy-page normalization work.
|
|
10
|
+
|
|
11
|
+
## 0.21.1 — 2026-06-12
|
|
12
|
+
|
|
13
|
+
**Webhook replay protection (security review M6).**
|
|
14
|
+
|
|
15
|
+
The webhook receiver now rejects deliveries whose `deliveredAt` falls
|
|
16
|
+
outside a ±5-minute window (401 `stale_delivery`; a missing field counts
|
|
17
|
+
as stale — the SaaS has sent it since v0.11) and dedupes `deliveryId`
|
|
18
|
+
re-sends inside that window (401 `duplicate_delivery`). Dedup is
|
|
19
|
+
best-effort in-memory per serverless instance; the time window is the
|
|
20
|
+
primary defense. The SaaS now stamps every delivery with a unique
|
|
21
|
+
`deliveryId` — payloads from a SaaS predating the field skip dedup and
|
|
22
|
+
keep working. Both fields sit inside the HMAC-signed JSON body, so an
|
|
23
|
+
attacker can't forge or strip them. No customer action needed; `^0.21`
|
|
24
|
+
picks this up automatically.
|
|
25
|
+
|
|
26
|
+
**Mode B block types caught up with the SaaS catalog (types only — no
|
|
27
|
+
runtime change).**
|
|
28
|
+
|
|
29
|
+
The `Block` union had drifted to 3 of the SaaS's 10 block components; it
|
|
30
|
+
now covers all of them — added `search`, `nav-recent-posts`,
|
|
31
|
+
`nav-related-posts`, `nav-category-index`, `image`, `slideshow`, and
|
|
32
|
+
`social-share` (plus the missing `NavSnapshotEntry.href` field). The types
|
|
33
|
+
are no longer a hand-maintained mirror: the monorepo's `@smking/shared`
|
|
34
|
+
package is the single source of truth and `src/cms-blocks.ts` is a
|
|
35
|
+
generated vendored copy (`pnpm sync:block-types`, guarded by a vitest
|
|
36
|
+
sync test). All block prop interfaces are also exported from the package
|
|
37
|
+
root now (`Block`, `BlockComponent`, `SearchProps`, …). Mode A
|
|
38
|
+
(`bodyHtml`) rendering is unaffected.
|
|
39
|
+
|
|
3
40
|
## 0.21.0 — 2026-06-09
|
|
4
41
|
|
|
5
42
|
**Per-site CMS theme — `<SmkingRuntime>` now mounts a `theme.css` stylesheet.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soloworks/smking-next",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.2",
|
|
4
4
|
"description": "AI-native SEO (AEO) for Next.js — auto-inject JSON-LD, FAQ, AI summary, and SEO metadata so AI crawlers (ChatGPT, Perplexity, Google AI) can cite your pages.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/sillyleo/smking/tree/main/packages/smking-next",
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
// GENERATED FILE — do not edit by hand.
|
|
2
|
+
// Vendored copy of packages/shared/src/types/cms-blocks.ts (the SDK ships
|
|
3
|
+
// raw src/ to npm, so it can't import the private workspace package).
|
|
4
|
+
// Regenerate with: pnpm sync:block-types
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CMS block primitives — substrate-pivot v2.
|
|
8
|
+
*
|
|
9
|
+
* Single source of truth for the Mode B (blocks JSON) contract shared by
|
|
10
|
+
* the SaaS dashboard (`apps/web/src/features/cms`) and the customer SDKs.
|
|
11
|
+
* The dashboard editor serializes Plate documents into this shape, the
|
|
12
|
+
* publish pipeline materializes nav snapshots into it, and
|
|
13
|
+
* `/api/v1/public/page` returns it verbatim.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ `@soloworks/smking-next` publishes raw `src/` to npm and cannot import
|
|
16
|
+
* this private workspace package — it ships a vendored copy at
|
|
17
|
+
* `packages/smking-next/src/cms-blocks.ts` instead. After editing this file
|
|
18
|
+
* run `pnpm sync:block-types` to regenerate it (drift fails the SDK's
|
|
19
|
+
* vitest guard).
|
|
20
|
+
*
|
|
21
|
+
* `article` is the serialize-time HTML container for runs of Plate built-in
|
|
22
|
+
* blocks — paragraphs / headings / lists are collapsed into one HTML body
|
|
23
|
+
* block via Plate's `serializeHtml`. New block primitives land here + in
|
|
24
|
+
* apps/web `block-schemas.ts`.
|
|
25
|
+
*
|
|
26
|
+
* `nav-taxonomy-list` uses a `source` discriminator — category mode is
|
|
27
|
+
* slug-prefix derived, tag mode targets `taxonomies.slug`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface HeroProps {
|
|
31
|
+
title: string;
|
|
32
|
+
/** Generic visible meta lines. Legacy drafts may still also carry subtitle. */
|
|
33
|
+
line1?: string;
|
|
34
|
+
line2?: string;
|
|
35
|
+
/** When true, render the backend page publish date. */
|
|
36
|
+
showPublishedAt?: boolean;
|
|
37
|
+
/** ISO or display date injected from page metadata at publish time. */
|
|
38
|
+
publishedAt?: string;
|
|
39
|
+
/** Legacy single-line fallback for SDK Mode B consumers. */
|
|
40
|
+
subtitle?: string;
|
|
41
|
+
image?: { url: string; alt: string };
|
|
42
|
+
cta?: { label: string; href: string };
|
|
43
|
+
/** Header layout — omitted = the original centred hero; "start" = the
|
|
44
|
+
* Apple-newsroom-style left-aligned header; "cover" = title-only banner over
|
|
45
|
+
* a full-bleed background image with a dark scrim (Apple services index);
|
|
46
|
+
* "cover-plain" = the same banner WITHOUT the scrim (image shown untouched).
|
|
47
|
+
* Visuals ship via bodyHtml; this mirrors the node attr for SDK Mode B
|
|
48
|
+
* (per-block) consumers. */
|
|
49
|
+
align?: "center" | "start" | "cover" | "cover-plain";
|
|
50
|
+
/** Crop ratio for cover heroes and the optional hero image.
|
|
51
|
+
* Omitted = auto, preserving the original image ratio. */
|
|
52
|
+
aspectRatio?: "auto" | "16:9" | "4:3";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ArticleProps {
|
|
56
|
+
/** Plate `serializeHtml` output — a contiguous run of built-in blocks
|
|
57
|
+
* (paragraphs / headings / lists) collapsed into one HTML body block. */
|
|
58
|
+
html: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface ModuleHeader {
|
|
62
|
+
heading?: string;
|
|
63
|
+
viewAll?: { label: string; href: string };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type NavLayout = "grid" | "list" | "carousel" | "archive";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Materialized snapshot entry written into nav-* block props at publish
|
|
70
|
+
* time (see apps/web `materialize-blocks.ts` for the resolver). The
|
|
71
|
+
* customer SDK echoes `snapshot` verbatim — it never round-trips back to
|
|
72
|
+
* smking for nav rendering, so if smking is down customer pages still
|
|
73
|
+
* render whatever was last published.
|
|
74
|
+
*/
|
|
75
|
+
export interface NavSnapshotEntry {
|
|
76
|
+
slug: string;
|
|
77
|
+
/**
|
|
78
|
+
* Absolute href = the customer's CMS mount prefix + slug (baked at publish
|
|
79
|
+
* from `sites.config.cmsBasePath`). Optional — falls back to `/${slug}`.
|
|
80
|
+
*/
|
|
81
|
+
href?: string;
|
|
82
|
+
title: string | null;
|
|
83
|
+
excerpt: string | null;
|
|
84
|
+
featuredImageUrl: string | null;
|
|
85
|
+
/** ISO string. */
|
|
86
|
+
publishedAt: string | null;
|
|
87
|
+
/** ISO string. Present when the article is manually pinned in CMS feeds. */
|
|
88
|
+
pinnedAt?: string | null;
|
|
89
|
+
contentType: "article" | "landing" | "listing" | "taxonomy" | null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type SearchQuickLinkSource = "category-by-path" | "tag" | "url";
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Optional links shown beside the search trigger on desktop and inside the
|
|
96
|
+
* search panel on small screens. Category/tag links keep their semantic source
|
|
97
|
+
* + path so publish-time materialization can bake the customer's CMS mount
|
|
98
|
+
* prefix into `href`; URL links carry the author's literal `href`.
|
|
99
|
+
*/
|
|
100
|
+
export interface SearchQuickLink {
|
|
101
|
+
label: string;
|
|
102
|
+
source: SearchQuickLinkSource;
|
|
103
|
+
/** Category slug path or flat tag slug. Not used for arbitrary URLs. */
|
|
104
|
+
path?: string;
|
|
105
|
+
/** Materialized href for category/tag links, or the author-entered URL. */
|
|
106
|
+
href?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `search` block — a command-palette over the WHOLE site's published CMS
|
|
111
|
+
* pages. Unlike `nav-taxonomy-list` (which scopes to a category / tag),
|
|
112
|
+
* search bakes EVERY published `cms_page` into `snapshot` at publish time
|
|
113
|
+
* and the `<smking-search>` web component filters the rendered cards
|
|
114
|
+
* client-side. Reuses `NavSnapshotEntry` (the static render only reads
|
|
115
|
+
* `href` / `title` / `excerpt` — no thumbnail, command-palette text feel).
|
|
116
|
+
*/
|
|
117
|
+
export interface SearchProps {
|
|
118
|
+
/** Input placeholder. Defaults to "Search…" at render time. */
|
|
119
|
+
placeholder?: string;
|
|
120
|
+
/** Optional heading above the search input. */
|
|
121
|
+
heading?: string;
|
|
122
|
+
/** Content column vs wide breakout (default wide). Mirrors the media
|
|
123
|
+
* blocks (image/embed). */
|
|
124
|
+
widthMode?: "content" | "wide";
|
|
125
|
+
/** Optional quick links adjacent to search. */
|
|
126
|
+
links?: SearchQuickLink[];
|
|
127
|
+
/** Publish-time materialized snapshot — every published page on the site. */
|
|
128
|
+
snapshot?: NavSnapshotEntry[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface RecentPostsProps extends ModuleHeader {
|
|
132
|
+
/** Author-set number of latest posts to show (1..50). */
|
|
133
|
+
limit: number;
|
|
134
|
+
/** Content column vs wide breakout (default content). Mirrors the media
|
|
135
|
+
* blocks. */
|
|
136
|
+
widthMode?: "content" | "wide";
|
|
137
|
+
/** Publish-time materialized snapshot — whole-site newest-first. */
|
|
138
|
+
snapshot?: NavSnapshotEntry[];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** One author-picked source for nav-related-posts. Same shape as
|
|
142
|
+
* `CategoryIndexItem` (a category path OR a tag), reused so the source picker
|
|
143
|
+
* UI stays identical across blocks. */
|
|
144
|
+
export interface RelatedPostsSource {
|
|
145
|
+
/** Slug prefix (category-by-path) or taxonomy slug (tag), per `source`. */
|
|
146
|
+
path: string;
|
|
147
|
+
/** Display name (only used by the editor chip; resolver ignores it). */
|
|
148
|
+
label: string;
|
|
149
|
+
/** Omitted = `category-by-path` (back-compat, mirrors CategoryIndexItem). */
|
|
150
|
+
source?: "category-by-path" | "tag";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface RelatedPostsProps extends ModuleHeader {
|
|
154
|
+
/** Authoring mode before materialization. Published blocks carry resolved
|
|
155
|
+
* `heading`; SDK consumers can ignore this field. */
|
|
156
|
+
headingMode?: "global" | "custom";
|
|
157
|
+
/** Author-set number of related posts to show (1..50). */
|
|
158
|
+
limit: number;
|
|
159
|
+
/** Content column vs wide breakout (default content). Mirrors the media
|
|
160
|
+
* blocks. */
|
|
161
|
+
widthMode?: "content" | "wide";
|
|
162
|
+
/** Author-picked sources. Empty/omitted → auto-fill from the CURRENT page's
|
|
163
|
+
* category, derived from its slug at publish time. When set, related posts
|
|
164
|
+
* are the latest across ALL picked sources (OR), newest-first, excluding the
|
|
165
|
+
* current page. */
|
|
166
|
+
sources?: RelatedPostsSource[];
|
|
167
|
+
/** Publish-time materialized snapshot. */
|
|
168
|
+
snapshot?: NavSnapshotEntry[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** One tag attached to the current article, materialized at publish time. */
|
|
172
|
+
export interface ArticleTagSnapshot {
|
|
173
|
+
slug: string;
|
|
174
|
+
name: string;
|
|
175
|
+
/** Virtual tag archive href, e.g. `/blog/tag/tofu-life`. */
|
|
176
|
+
href: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* `article-tags` block — renders the current article's CMS tags as chips.
|
|
181
|
+
* The tags are page metadata, not authored inside the block, so `snapshot`
|
|
182
|
+
* is filled at publish time from `content_taxonomies`.
|
|
183
|
+
*/
|
|
184
|
+
export interface ArticleTagsProps {
|
|
185
|
+
/** Optional section heading. Omitted / empty means no heading. */
|
|
186
|
+
heading?: string;
|
|
187
|
+
/** Publish-time materialized current-article tags. */
|
|
188
|
+
snapshot?: ArticleTagSnapshot[];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** One author-selected category OR tag in a nav-category-index block. */
|
|
192
|
+
export interface CategoryIndexItem {
|
|
193
|
+
/** Slug prefix (category-by-path) or taxonomy slug (tag), per `source`.
|
|
194
|
+
* e.g. category `blog/seo`, tag `announcements`. */
|
|
195
|
+
path: string;
|
|
196
|
+
/** Display name on the card (slug leaf / tag name by default; author-editable). */
|
|
197
|
+
label: string;
|
|
198
|
+
/** Source discriminator (mirrors nav-taxonomy-list). Omitted =
|
|
199
|
+
* `category-by-path` — back-compat: pre-tag nodes carry no source. A `tag`
|
|
200
|
+
* card shows that tag's latest post + links to its archive. */
|
|
201
|
+
source?: "category-by-path" | "tag";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Materialized card for nav-category-index: the category's name, its archive
|
|
206
|
+
* link, and its latest post (null when the category has no published posts).
|
|
207
|
+
*/
|
|
208
|
+
export interface CategoryCardSnapshot {
|
|
209
|
+
label: string;
|
|
210
|
+
/** Archive href = mount prefix + category path (baked at publish). */
|
|
211
|
+
href: string;
|
|
212
|
+
latest: NavSnapshotEntry | null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface CategoryIndexProps extends ModuleHeader {
|
|
216
|
+
/** `auto` = listing categories directly under `autoBasePath` at publish
|
|
217
|
+
* time; `manual` = exactly the picked items below. Omitted = back-compat:
|
|
218
|
+
* items present means manual, otherwise auto. */
|
|
219
|
+
mode?: "auto" | "manual";
|
|
220
|
+
/** Auto mode source. Omitted = category hierarchy, for back-compat. */
|
|
221
|
+
autoSource?: "category-by-path" | "tag";
|
|
222
|
+
/** Auto mode source hierarchy. Empty string means root-level listing
|
|
223
|
+
* categories; any other value is a listing slug whose direct child listing
|
|
224
|
+
* pages should be shown. Omitted falls back to root for new materialization. */
|
|
225
|
+
autoBasePath?: string;
|
|
226
|
+
items: CategoryIndexItem[];
|
|
227
|
+
/** Card layout — omitted/"cards" = category cards (archive links);
|
|
228
|
+
* "featured" = Apple-newsroom services-index tiles where each card IS the
|
|
229
|
+
* category's latest article. Visuals ship via bodyHtml; this mirrors the
|
|
230
|
+
* node attr for SDK Mode B (per-block) consumers. */
|
|
231
|
+
layout?: "cards" | "featured";
|
|
232
|
+
/** Content column vs wide breakout. Omitted = layout-derived default
|
|
233
|
+
* (cards → content, featured → wide); set explicitly to override. */
|
|
234
|
+
widthMode?: "content" | "wide";
|
|
235
|
+
snapshot?: CategoryCardSnapshot[];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export type MediaAspectRatio = "auto" | "16:9" | "4:3" | "square";
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* `image` block — a single figure (image + optional caption). `widthMode`
|
|
242
|
+
* controls the breakout (content = text column, wide = up to 1024px centred
|
|
243
|
+
* on the container). All fields optional so an unconfigured block is valid;
|
|
244
|
+
* the static render emits nothing until an image is uploaded.
|
|
245
|
+
*/
|
|
246
|
+
export interface ImageProps {
|
|
247
|
+
url?: string;
|
|
248
|
+
alt?: string;
|
|
249
|
+
caption?: string;
|
|
250
|
+
widthMode?: "content" | "wide";
|
|
251
|
+
/** Plain image vs soft neutral tonal surface. */
|
|
252
|
+
styleVariant?: "plain" | "tonal";
|
|
253
|
+
/** Omitted / auto preserves the original image ratio. Fixed values crop. */
|
|
254
|
+
aspectRatio?: MediaAspectRatio;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** One slide in a `slideshow` block. */
|
|
258
|
+
export interface SlideshowSlideProps {
|
|
259
|
+
url: string;
|
|
260
|
+
alt?: string;
|
|
261
|
+
caption?: string;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* `slideshow` block — an image carousel. `widthMode` controls the breakout
|
|
266
|
+
* (content / wide). `slides` optional so an unconfigured block is valid; the
|
|
267
|
+
* static render emits nothing (and the customer SDK upgrades it into a
|
|
268
|
+
* paginated carousel via the `<smking-slideshow>` web component).
|
|
269
|
+
*/
|
|
270
|
+
export interface SlideshowProps {
|
|
271
|
+
slides?: SlideshowSlideProps[];
|
|
272
|
+
widthMode?: "content" | "wide";
|
|
273
|
+
/** Fixed crop ratio for every slide (render default 16:9). */
|
|
274
|
+
aspectRatio?: "16:9" | "4:3";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** One image in a `collage` block. Width/height are optional natural
|
|
278
|
+
* dimensions saved at authoring time so the auto layout can keep row
|
|
279
|
+
* proportions stable without loading images first. */
|
|
280
|
+
export interface CollageImageProps {
|
|
281
|
+
url: string;
|
|
282
|
+
alt?: string;
|
|
283
|
+
width?: number;
|
|
284
|
+
height?: number;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* `collage` block — a 1–6 image composition. `auto` uses natural image
|
|
289
|
+
* ratios and the wide breakout; `row-grid` keeps each row at one height while
|
|
290
|
+
* allowing different rows to have different proportions. Content width forces
|
|
291
|
+
* `row-grid` at render time.
|
|
292
|
+
*/
|
|
293
|
+
export interface CollageProps {
|
|
294
|
+
images?: CollageImageProps[];
|
|
295
|
+
widthMode?: "content" | "wide";
|
|
296
|
+
mode?: "auto" | "row-grid";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export type SocialSharePlatform =
|
|
300
|
+
| "threads"
|
|
301
|
+
| "instagram"
|
|
302
|
+
| "facebook"
|
|
303
|
+
| "line"
|
|
304
|
+
| "copy";
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* `social-share` block — Threads / Instagram / Facebook / LINE share links +
|
|
308
|
+
* copy-link.
|
|
309
|
+
* Platform visibility is site-wide; `shareUrl` is the optional no-JS fallback
|
|
310
|
+
* target (the `<smking-share>` web component overrides every link from
|
|
311
|
+
* window.location at runtime, so the live share always points at the real
|
|
312
|
+
* current page).
|
|
313
|
+
*/
|
|
314
|
+
export interface SocialShareProps {
|
|
315
|
+
shareUrl?: string;
|
|
316
|
+
/** Global mode reads the site-wide share title. Custom mode reads `label`.
|
|
317
|
+
* Omitted = global; empty resolved title = bare icon row. */
|
|
318
|
+
headingMode?: "global" | "custom";
|
|
319
|
+
label?: string;
|
|
320
|
+
/** Site-wide platform visibility snapshot baked into published output. */
|
|
321
|
+
platforms?: SocialSharePlatform[];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface NavTaxonomyListProps extends ModuleHeader {
|
|
325
|
+
/**
|
|
326
|
+
* Discriminator:
|
|
327
|
+
* - `category-by-path` → `path` is a slug prefix (e.g. `/blog/seo`); SDK
|
|
328
|
+
* queries pages whose slug starts with this prefix.
|
|
329
|
+
* - `tag` → `path` is the taxonomy slug (flat tag namespace).
|
|
330
|
+
*/
|
|
331
|
+
source: "category-by-path" | "tag";
|
|
332
|
+
path: string;
|
|
333
|
+
limit: number;
|
|
334
|
+
/** List EVERY published article under the category/tag instead of the
|
|
335
|
+
* latest `limit` (Apple archive page). The resolver still applies a 200
|
|
336
|
+
* safety cap. Omitted (not false) when off — back-compat shape. */
|
|
337
|
+
showAll?: boolean;
|
|
338
|
+
layout: NavLayout;
|
|
339
|
+
/** Content column vs wide breakout (default content). Mirrors the media
|
|
340
|
+
* blocks. */
|
|
341
|
+
widthMode?: "content" | "wide";
|
|
342
|
+
/** Publish-time materialized snapshot. */
|
|
343
|
+
snapshot?: NavSnapshotEntry[];
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** One button in a `button-group` block. */
|
|
347
|
+
export interface ButtonItemProps {
|
|
348
|
+
label: string;
|
|
349
|
+
href: string;
|
|
350
|
+
/** Apple-style looks: `filled` = primary capsule, `tinted` = soft primary
|
|
351
|
+
* wash, `outline` = bordered. Omitted = filled. */
|
|
352
|
+
variant?: "filled" | "tinted" | "outline";
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* `button-group` block — a row of Apple-style capsule CTAs. Buttons wrap on
|
|
357
|
+
* narrow screens; `align` positions the row. Empty `buttons` is valid (an
|
|
358
|
+
* unconfigured block renders nothing on the customer side).
|
|
359
|
+
*/
|
|
360
|
+
export interface ButtonGroupProps {
|
|
361
|
+
buttons?: ButtonItemProps[];
|
|
362
|
+
align?: "start" | "center";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* `embed` block — a privacy-friendly YouTube embed (youtube-nocookie
|
|
367
|
+
* iframe). `url` accepts any YouTube form (watch / youtu.be / shorts /
|
|
368
|
+
* embed); the video id is resolved at render time. No url / unrecognised
|
|
369
|
+
* url = renders nothing on the customer side. `widthMode` matches the other
|
|
370
|
+
* media blocks (content column vs wide breakout).
|
|
371
|
+
*/
|
|
372
|
+
export interface EmbedProps {
|
|
373
|
+
url?: string;
|
|
374
|
+
caption?: string;
|
|
375
|
+
widthMode?: "content" | "wide";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* `media-text` block — Apple-style feature row: image on one side, RICH
|
|
380
|
+
* EDITABLE text on the other. Unlike the attr-panel blocks, the text is
|
|
381
|
+
* real Plate content (the node's children — headings/paragraphs edited
|
|
382
|
+
* inline); `html` carries its serialized form for Mode B consumers, while
|
|
383
|
+
* Mode A ships the full markup inside bodyHtml as usual.
|
|
384
|
+
*/
|
|
385
|
+
export interface MediaTextProps {
|
|
386
|
+
imageUrl?: string;
|
|
387
|
+
imageAlt?: string;
|
|
388
|
+
/** Which side the IMAGE sits on (md+ screens; stacks on mobile). */
|
|
389
|
+
side?: "left" | "right";
|
|
390
|
+
/** Content column vs wide breakout (default wide). Mirrors the media
|
|
391
|
+
* blocks. */
|
|
392
|
+
widthMode?: "content" | "wide";
|
|
393
|
+
/** Plain row vs soft neutral tonal surface. */
|
|
394
|
+
styleVariant?: "plain" | "tonal";
|
|
395
|
+
/** Omitted / auto preserves the original image ratio. Fixed values crop. */
|
|
396
|
+
aspectRatio?: MediaAspectRatio;
|
|
397
|
+
/** Serialized text-column HTML (class-stripped, same treatment as
|
|
398
|
+
* `article`). */
|
|
399
|
+
html: string;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* One slot in a `latest-news` block — sourced independently from EITHER a
|
|
404
|
+
* specific article (by slug) or a category (shows that category's latest
|
|
405
|
+
* post). The discriminator mirrors `nav-taxonomy-list`'s source pattern.
|
|
406
|
+
*/
|
|
407
|
+
export type LatestNewsSlot =
|
|
408
|
+
| { kind: "article"; slug: string }
|
|
409
|
+
| {
|
|
410
|
+
kind: "category";
|
|
411
|
+
path: string;
|
|
412
|
+
label: string;
|
|
413
|
+
/** Source discriminator (mirrors nav-taxonomy-list). Omitted =
|
|
414
|
+
* `category-by-path` — back-compat. A `tag` slot shows the latest post
|
|
415
|
+
* carrying that tag. */
|
|
416
|
+
source?: "category-by-path" | "tag";
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* One materialized `latest-news` card: the resolved article entry (the
|
|
421
|
+
* article itself for an `article` slot, or the category's latest post for a
|
|
422
|
+
* `category` slot) plus an optional eyebrow (the category label for category
|
|
423
|
+
* slots; null for article slots). A null `entry` = the slot's source
|
|
424
|
+
* resolved to nothing and is skipped at render.
|
|
425
|
+
*/
|
|
426
|
+
export interface LatestNewsCardSnapshot {
|
|
427
|
+
eyebrow: string | null;
|
|
428
|
+
entry: NavSnapshotEntry | null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* `latest-news` block — the Apple-newsroom "Latest News" magazine grid.
|
|
433
|
+
* 3–6 author-curated slots; the first is the large feature card, the rest
|
|
434
|
+
* scale down to medium / small tiles (6 = 1 feature + 2 medium + 3 small,
|
|
435
|
+
* down to 3 = 1 feature + 2 medium). Materialized at publish like the nav-*
|
|
436
|
+
* feeds; reuses `NavSnapshotEntry` for each resolved card.
|
|
437
|
+
*/
|
|
438
|
+
export interface LatestNewsProps extends ModuleHeader {
|
|
439
|
+
/** Author-curated slots, 3–6, in display order (first = feature card). */
|
|
440
|
+
slots: LatestNewsSlot[];
|
|
441
|
+
/** Content column vs wide breakout (default wide). Mirrors the media
|
|
442
|
+
* blocks. */
|
|
443
|
+
widthMode?: "content" | "wide";
|
|
444
|
+
/** Publish-time materialized snapshot — one card per slot, same order. */
|
|
445
|
+
snapshot?: LatestNewsCardSnapshot[];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export type Block =
|
|
449
|
+
| { component: "hero"; id: string; props: HeroProps }
|
|
450
|
+
| { component: "article"; id: string; props: ArticleProps }
|
|
451
|
+
| {
|
|
452
|
+
component: "nav-taxonomy-list";
|
|
453
|
+
id: string;
|
|
454
|
+
props: NavTaxonomyListProps;
|
|
455
|
+
}
|
|
456
|
+
| { component: "search"; id: string; props: SearchProps }
|
|
457
|
+
| { component: "nav-recent-posts"; id: string; props: RecentPostsProps }
|
|
458
|
+
| { component: "nav-related-posts"; id: string; props: RelatedPostsProps }
|
|
459
|
+
| { component: "article-tags"; id: string; props: ArticleTagsProps }
|
|
460
|
+
| { component: "nav-category-index"; id: string; props: CategoryIndexProps }
|
|
461
|
+
| { component: "image"; id: string; props: ImageProps }
|
|
462
|
+
| { component: "slideshow"; id: string; props: SlideshowProps }
|
|
463
|
+
| { component: "collage"; id: string; props: CollageProps }
|
|
464
|
+
| { component: "social-share"; id: string; props: SocialShareProps }
|
|
465
|
+
| { component: "button-group"; id: string; props: ButtonGroupProps }
|
|
466
|
+
| { component: "embed"; id: string; props: EmbedProps }
|
|
467
|
+
| { component: "media-text"; id: string; props: MediaTextProps }
|
|
468
|
+
| { component: "latest-news"; id: string; props: LatestNewsProps };
|
|
469
|
+
|
|
470
|
+
export type BlockComponent = Block["component"];
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,12 @@ export { getCmsPage } from "./lib/cms-client";
|
|
|
6
6
|
export type {
|
|
7
7
|
AeoResponse,
|
|
8
8
|
AeoStatus,
|
|
9
|
+
ArticleProps,
|
|
10
|
+
Block,
|
|
11
|
+
BlockComponent,
|
|
12
|
+
CategoryCardSnapshot,
|
|
13
|
+
CategoryIndexItem,
|
|
14
|
+
CategoryIndexProps,
|
|
9
15
|
ChatLinks,
|
|
10
16
|
CmsPage,
|
|
11
17
|
CmsParams,
|
|
@@ -13,5 +19,20 @@ export type {
|
|
|
13
19
|
CmsStatus,
|
|
14
20
|
DiscoverParams,
|
|
15
21
|
FaqItem,
|
|
22
|
+
HeroProps,
|
|
23
|
+
ImageProps,
|
|
24
|
+
ModuleHeader,
|
|
25
|
+
NavLayout,
|
|
26
|
+
NavSnapshotEntry,
|
|
27
|
+
NavTaxonomyListProps,
|
|
28
|
+
RecentPostsProps,
|
|
29
|
+
RelatedPostsProps,
|
|
30
|
+
SearchQuickLink,
|
|
31
|
+
SearchQuickLinkSource,
|
|
32
|
+
SearchProps,
|
|
16
33
|
SeoMeta,
|
|
34
|
+
SlideshowProps,
|
|
35
|
+
SlideshowSlideProps,
|
|
36
|
+
SocialSharePlatform,
|
|
37
|
+
SocialShareProps,
|
|
17
38
|
} from "./types";
|
package/src/lib/webhook-route.ts
CHANGED
|
@@ -7,6 +7,35 @@ interface WebhookPayload {
|
|
|
7
7
|
paths?: string[];
|
|
8
8
|
slugs?: string[];
|
|
9
9
|
deliveredAt?: string;
|
|
10
|
+
deliveryId?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Replay protection (v0.21.1+). The deliveredAt window is the primary
|
|
14
|
+
// defense — ±5min absorbs clock skew between SaaS and customer while
|
|
15
|
+
// capping how long an intercepted delivery stays replayable. deliveryId
|
|
16
|
+
// dedup is best-effort on top: serverless instances each hold their own
|
|
17
|
+
// Map, so a replay landing on a different instance only has the window
|
|
18
|
+
// to beat. SaaS payloads predating deliveryId pass dedup untouched.
|
|
19
|
+
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
|
|
20
|
+
const SEEN_DELIVERIES_MAX = 1000;
|
|
21
|
+
const seenDeliveries = new Map<string, number>(); // deliveryId → expiry (ms)
|
|
22
|
+
|
|
23
|
+
function isDuplicateDelivery(
|
|
24
|
+
deliveryId: string | undefined,
|
|
25
|
+
now: number,
|
|
26
|
+
): boolean {
|
|
27
|
+
for (const [id, expiry] of seenDeliveries) {
|
|
28
|
+
if (expiry <= now) seenDeliveries.delete(id);
|
|
29
|
+
}
|
|
30
|
+
if (typeof deliveryId !== "string" || deliveryId.length === 0) return false;
|
|
31
|
+
if (seenDeliveries.has(deliveryId)) return true;
|
|
32
|
+
if (seenDeliveries.size >= SEEN_DELIVERIES_MAX) {
|
|
33
|
+
// Map iterates in insertion order — evict the oldest entry.
|
|
34
|
+
const oldest = seenDeliveries.keys().next().value;
|
|
35
|
+
if (oldest !== undefined) seenDeliveries.delete(oldest);
|
|
36
|
+
}
|
|
37
|
+
seenDeliveries.set(deliveryId, now + REPLAY_WINDOW_MS);
|
|
38
|
+
return false;
|
|
10
39
|
}
|
|
11
40
|
|
|
12
41
|
/**
|
|
@@ -41,6 +70,13 @@ interface WebhookPayload {
|
|
|
41
70
|
* Auth: HMAC-SHA256 only. Bearer dropped — single auth model means one
|
|
42
71
|
* env var, one shim, fewer customer mis-config paths (the disambiguation
|
|
43
72
|
* problem the v0.10 dual-handler shipped with).
|
|
73
|
+
*
|
|
74
|
+
* Replay protection (v0.21.1+): a signed delivery is only accepted while
|
|
75
|
+
* `deliveredAt` sits inside a ±5min window (401 `stale_delivery`
|
|
76
|
+
* otherwise — missing field counts as stale), and a `deliveryId` seen
|
|
77
|
+
* before within that window is rejected (401 `duplicate_delivery`).
|
|
78
|
+
* Payloads from a SaaS predating `deliveryId` skip dedup — the window
|
|
79
|
+
* remains the primary defense.
|
|
44
80
|
*/
|
|
45
81
|
export async function POST(request: Request): Promise<Response> {
|
|
46
82
|
const secret = process.env.SMKING_WEBHOOK_SECRET;
|
|
@@ -66,6 +102,23 @@ export async function POST(request: Request): Promise<Response> {
|
|
|
66
102
|
return Response.json({ error: "invalid_payload" }, { status: 400 });
|
|
67
103
|
}
|
|
68
104
|
|
|
105
|
+
// Replay protection — deliveredAt must sit inside a ±5min window
|
|
106
|
+
// (missing/unparseable counts as stale; SaaS has sent it since v0.11),
|
|
107
|
+
// then deliveryId dedup rejects re-sends of a delivery we already saw.
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const deliveredAtMs = payload.deliveredAt
|
|
110
|
+
? Date.parse(payload.deliveredAt)
|
|
111
|
+
: Number.NaN;
|
|
112
|
+
if (
|
|
113
|
+
Number.isNaN(deliveredAtMs) ||
|
|
114
|
+
Math.abs(now - deliveredAtMs) > REPLAY_WINDOW_MS
|
|
115
|
+
) {
|
|
116
|
+
return Response.json({ error: "stale_delivery" }, { status: 401 });
|
|
117
|
+
}
|
|
118
|
+
if (isDuplicateDelivery(payload.deliveryId, now)) {
|
|
119
|
+
return Response.json({ error: "duplicate_delivery" }, { status: 401 });
|
|
120
|
+
}
|
|
121
|
+
|
|
69
122
|
const kind = payload.kind;
|
|
70
123
|
if (typeof kind !== "string" || kind.length === 0) {
|
|
71
124
|
// Forward-compat: SaaS may emit kinds we haven't taught the SDK
|
package/src/types.ts
CHANGED
|
@@ -56,7 +56,7 @@ export interface AeoResponse {
|
|
|
56
56
|
|
|
57
57
|
export type CmsStatus = "ready" | "preview" | "pending" | "not_found";
|
|
58
58
|
|
|
59
|
-
export type CmsContentType = "article" | "landing" | "listing";
|
|
59
|
+
export type CmsContentType = "article" | "landing" | "listing" | "taxonomy";
|
|
60
60
|
|
|
61
61
|
// `CmsThemeMode` removed in v0.15.0 — Mode B (`tailwind-prose`) dropped
|
|
62
62
|
// in favour of the canonical Plate-serialized HTML render (Mode A only).
|
|
@@ -64,58 +64,37 @@ export type CmsContentType = "article" | "landing" | "listing";
|
|
|
64
64
|
// `<article class="prose">` around `<SmkingCms>` themselves.
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* v2 substrate block primitives.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
67
|
+
* v2 substrate block primitives. Single source of truth lives in the
|
|
68
|
+
* monorepo's `packages/shared/src/types/cms-blocks.ts`; this package ships
|
|
69
|
+
* a vendored copy (`./cms-blocks`, regenerated via `pnpm sync:block-types`)
|
|
70
|
+
* because raw `src/` is published to npm and can't depend on the private
|
|
71
|
+
* workspace package. A vitest guard fails when the copy drifts.
|
|
72
72
|
*/
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
excerpt: string | null;
|
|
99
|
-
featuredImageUrl: string | null;
|
|
100
|
-
/** ISO string. */
|
|
101
|
-
publishedAt: string | null;
|
|
102
|
-
contentType: "article" | "landing" | "listing" | null;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export interface NavTaxonomyListProps extends ModuleHeader {
|
|
106
|
-
/** category-by-path: slug prefix; tag: taxonomies.slug */
|
|
107
|
-
source: "category-by-path" | "tag";
|
|
108
|
-
path: string;
|
|
109
|
-
limit: number;
|
|
110
|
-
layout: NavLayout;
|
|
111
|
-
/** Publish-time materialized — never fetched live. */
|
|
112
|
-
snapshot?: NavSnapshotEntry[];
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export type Block =
|
|
116
|
-
| { component: "hero"; id: string; props: HeroProps }
|
|
117
|
-
| { component: "article"; id: string; props: ArticleProps }
|
|
118
|
-
| { component: "nav-taxonomy-list"; id: string; props: NavTaxonomyListProps };
|
|
73
|
+
import type { Block } from "./cms-blocks";
|
|
74
|
+
|
|
75
|
+
export type {
|
|
76
|
+
ArticleProps,
|
|
77
|
+
Block,
|
|
78
|
+
BlockComponent,
|
|
79
|
+
CategoryCardSnapshot,
|
|
80
|
+
CategoryIndexItem,
|
|
81
|
+
CategoryIndexProps,
|
|
82
|
+
HeroProps,
|
|
83
|
+
ImageProps,
|
|
84
|
+
ModuleHeader,
|
|
85
|
+
NavLayout,
|
|
86
|
+
NavSnapshotEntry,
|
|
87
|
+
NavTaxonomyListProps,
|
|
88
|
+
RecentPostsProps,
|
|
89
|
+
RelatedPostsProps,
|
|
90
|
+
SearchQuickLink,
|
|
91
|
+
SearchQuickLinkSource,
|
|
92
|
+
SearchProps,
|
|
93
|
+
SlideshowProps,
|
|
94
|
+
SlideshowSlideProps,
|
|
95
|
+
SocialSharePlatform,
|
|
96
|
+
SocialShareProps,
|
|
97
|
+
} from "./cms-blocks";
|
|
119
98
|
|
|
120
99
|
/**
|
|
121
100
|
* A single published CMS page returned by the smking public API
|