@xexr/blog-platform 1.1.7 → 1.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e336ad5: Add optional article videos. `PublishedArticle.video` carries a YouTube video that replaces the featured image at the top of the article as a click-to-play preview (the featured image is the poster, and the privacy-enhanced `youtube-nocookie.com` player loads only after the reader presses play). The BlogPosting structured data gains a `VideoObject`, the publish endpoint validates the video, and the video is stored in `meta_json`, so no host migration is needed. Hosts that cache article data should bump their cache key, since the stored article shape changed.
8
+
3
9
  ## 1.1.7
4
10
 
5
11
  ### Patch Changes
@@ -47,7 +53,6 @@
47
53
  This release requires consumers to use drizzle-orm >=0.44 and @libsql/client >=0.15.
48
54
 
49
55
  Updated packages:
50
-
51
56
  - @libsql/client: ^0.14.0 → ^0.15.0
52
57
  - drizzle-orm: ^0.38.0 → ^0.44.0
53
58
  - @radix-ui/react-separator: ^1.1.0 → ^1.1.1
package/README.md CHANGED
@@ -775,6 +775,27 @@ The blog platform displays author information based on Bloggy's author settings:
775
775
  "Oct 4, 2025 • 4 min read • From Indie Web Growth"
776
776
  ```
777
777
 
778
+ ### Article Videos
779
+
780
+ An article can carry an optional YouTube video (`article.video`, available from 1.2.0):
781
+
782
+ ```json
783
+ "video": {
784
+ "provider": "youtube",
785
+ "id": "4kXvcIqw0RQ",
786
+ "title": "The Eisenhower matrix",
787
+ "uploadDate": "2026-10-01",
788
+ "duration": "PT4M13S"
789
+ }
790
+ ```
791
+
792
+ - The video takes the featured image's place at the top of the article, using the featured image as its poster.
793
+ - It is click-to-play: nothing is requested from YouTube until the reader presses play, then the `youtube-nocookie.com` player loads with autoplay. Without JavaScript the poster links to the YouTube watch page.
794
+ - The BlogPosting JSON-LD gains a `VideoObject` (`description` falls back to the article excerpt).
795
+ - `verticalId` is reserved for a future vertical cut and is not rendered yet.
796
+ - The publish endpoint rejects a malformed video (`PUB001`). Older packages silently ignore the field, so Bloggy checks `/api/blog/version` first and refuses to send a video to a platform older than 1.2.0.
797
+ - Mention YouTube embeds in your privacy policy: pressing play sends the reader's IP address and device details to Google.
798
+
778
799
  ### SEO Best Practices
779
800
 
780
801
  When publishing articles, the blog platform automatically handles:
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { validatePublishPayload } from "./route";
4
+
5
+ function payload(articleOverrides: Record<string, unknown> = {}) {
6
+ return {
7
+ idempotencyKey: "key-1",
8
+ article: {
9
+ id: "article-1",
10
+ slug: "eisenhower-matrix",
11
+ title: "The Eisenhower matrix",
12
+ content: "Body",
13
+ publishedAt: "2026-11-06T10:00:00.000Z",
14
+ site: { id: "site-1", name: "dypt", domain: "dypt.app" },
15
+ meta: { keywords: [] },
16
+ ...articleOverrides,
17
+ },
18
+ };
19
+ }
20
+
21
+ describe("validatePublishPayload video", () => {
22
+ it("accepts articles without a video", () => {
23
+ expect(validatePublishPayload(payload())).toEqual([]);
24
+ });
25
+
26
+ it("accepts a valid YouTube video", () => {
27
+ expect(
28
+ validatePublishPayload(
29
+ payload({
30
+ video: {
31
+ provider: "youtube",
32
+ id: "4kXvcIqw0RQ",
33
+ title: "The Eisenhower matrix",
34
+ uploadDate: "2026-10-01",
35
+ },
36
+ }),
37
+ ),
38
+ ).toEqual([]);
39
+ });
40
+
41
+ it("rejects a malformed video instead of storing it", () => {
42
+ expect(
43
+ validatePublishPayload(
44
+ payload({
45
+ video: {
46
+ provider: "youtube",
47
+ id: "javascript:alert(1)",
48
+ title: "Bad",
49
+ uploadDate: "2026-10-01",
50
+ },
51
+ }),
52
+ ),
53
+ ).toEqual(["Article video id must be an 11-character YouTube id"]);
54
+ });
55
+ });
@@ -8,6 +8,7 @@ import {
8
8
  } from "@/lib/auth";
9
9
  import { enforceRateLimit, getRateLimitHeaders } from "@/lib/ratelimit";
10
10
  import { createCorsResponse, handleCorsOptions } from "@/lib/cors";
11
+ import { validatePublishedArticleVideo } from "@/lib/video";
11
12
  import type {
12
13
  PublishPayload,
13
14
  PublishResult,
@@ -119,7 +120,7 @@ export async function POST(
119
120
  success: false,
120
121
  error: {
121
122
  code: "PUB001",
122
- message: "Missing required fields",
123
+ message: "Invalid or missing fields",
123
124
  details: { errors: validationErrors },
124
125
  },
125
126
  idempotencyKey: payload.idempotencyKey ?? "",
@@ -286,7 +287,7 @@ export async function OPTIONS(request: NextRequest): Promise<NextResponse> {
286
287
  /**
287
288
  * Validate the publish payload structure
288
289
  */
289
- function validatePublishPayload(payload: unknown): string[] {
290
+ export function validatePublishPayload(payload: unknown): string[] {
290
291
  const errors: string[] = [];
291
292
 
292
293
  if (!payload || typeof payload !== "object") {
@@ -341,6 +342,10 @@ function validatePublishPayload(payload: unknown): string[] {
341
342
  errors.push("Article meta.keywords must be an array");
342
343
  }
343
344
  }
345
+
346
+ if (article.video !== undefined && article.video !== null) {
347
+ errors.push(...validatePublishedArticleVideo(article.video));
348
+ }
344
349
  }
345
350
 
346
351
  return errors;
@@ -16,6 +16,8 @@ import { Card, CardContent } from "../../../components/ui/card";
16
16
  import ArticleTopActions from "../../../components/ArticleTopActions";
17
17
  import { ExpandableArticleImage } from "../../../components/ExpandableArticleImage";
18
18
  import { ArticleReadingProgress } from "../../../components/ArticleReadingProgress";
19
+ import { ArticleVideo } from "../../../components/ArticleVideo";
20
+ import { isYouTubeVideoId } from "../../../lib/video";
19
21
  import { Separator } from "../../../components/ui/separator";
20
22
  import {
21
23
  generateCompleteArticleStructuredData,
@@ -166,6 +168,19 @@ export default function ArticlePage({
166
168
  article.featuredImageCreditText ??
167
169
  article.featuredImageCreditUrl,
168
170
  );
171
+ const featuredImageCaption = hasFeaturedImageMeta ? (
172
+ <figcaption className="text-muted-foreground mt-3 space-y-1 px-2 text-center text-sm leading-relaxed sm:px-0">
173
+ {article.featuredImageCaption && <p>{article.featuredImageCaption}</p>}
174
+ {(article.featuredImageCreditText ?? article.featuredImageCreditUrl) && (
175
+ <p className="text-xs">
176
+ <FeaturedImageCredit
177
+ creditText={article.featuredImageCreditText}
178
+ creditUrl={article.featuredImageCreditUrl}
179
+ />
180
+ </p>
181
+ )}
182
+ </figcaption>
183
+ ) : null;
169
184
  const resolvedRelatedArticles =
170
185
  relatedArticles.length > 0
171
186
  ? relatedArticles.slice(0, 3)
@@ -340,37 +355,35 @@ export default function ArticlePage({
340
355
  </div>
341
356
  </header>
342
357
 
343
- {/* Featured Image */}
344
- {imageUrl && (
345
- <figure className="article-featured-image mx-auto my-8 w-full max-w-4xl sm:my-12 lg:my-16">
346
- <Image
347
- src={imageUrl}
348
- alt={
349
- article.featuredImageAlt ?? `Featured image for ${article.title}`
358
+ {/* Video (replaces the featured image, which becomes its poster) */}
359
+ {article.video && isYouTubeVideoId(article.video.id) ? (
360
+ <figure className="article-featured-video mx-auto my-8 w-full max-w-4xl sm:my-12 lg:my-16">
361
+ <ArticleVideo
362
+ video={article.video}
363
+ posterSrc={imageUrl}
364
+ posterAlt={
365
+ article.featuredImageAlt ?? `Video poster for ${article.title}`
350
366
  }
351
- className="h-auto w-full rounded-3xl border border-[var(--blog-accent-border)] shadow-[0_24px_80px_rgb(168_85_247_/_0.14)]"
352
- width={1200}
353
- height={800}
354
- priority
355
367
  />
356
-
357
- {hasFeaturedImageMeta && (
358
- <figcaption className="text-muted-foreground mt-3 space-y-1 px-2 text-center text-sm leading-relaxed sm:px-0">
359
- {article.featuredImageCaption && (
360
- <p>{article.featuredImageCaption}</p>
361
- )}
362
- {(article.featuredImageCreditText ??
363
- article.featuredImageCreditUrl) && (
364
- <p className="text-xs">
365
- <FeaturedImageCredit
366
- creditText={article.featuredImageCreditText}
367
- creditUrl={article.featuredImageCreditUrl}
368
- />
369
- </p>
370
- )}
371
- </figcaption>
372
- )}
368
+ {imageUrl && featuredImageCaption}
373
369
  </figure>
370
+ ) : (
371
+ imageUrl && (
372
+ <figure className="article-featured-image mx-auto my-8 w-full max-w-4xl sm:my-12 lg:my-16">
373
+ <Image
374
+ src={imageUrl}
375
+ alt={
376
+ article.featuredImageAlt ??
377
+ `Featured image for ${article.title}`
378
+ }
379
+ className="h-auto w-full rounded-3xl border border-[var(--blog-accent-border)] shadow-[0_24px_80px_rgb(168_85_247_/_0.14)]"
380
+ width={1200}
381
+ height={800}
382
+ priority
383
+ />
384
+ {featuredImageCaption}
385
+ </figure>
386
+ )
374
387
  )}
375
388
 
376
389
  {/* Article Content */}
@@ -0,0 +1,136 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import Image, { type ImageLoader } from "next/image.js";
5
+ import { Play } from "lucide-react";
6
+
7
+ import { cn } from "../lib/utils";
8
+ import {
9
+ buildYouTubeEmbedUrl,
10
+ buildYouTubeWatchUrl,
11
+ isYouTubeVideoId,
12
+ } from "../lib/video";
13
+ import type { PublishedArticleVideo } from "../types/contracts";
14
+
15
+ interface ArticleVideoProps {
16
+ video: PublishedArticleVideo;
17
+ /** Poster image, normally the article's featured image. */
18
+ posterSrc?: string;
19
+ posterAlt?: string;
20
+ /**
21
+ * Image loader for the poster. Needed by hosts whose Next config sets
22
+ * `images.loader: "custom"` without a loader file.
23
+ */
24
+ posterLoader?: ImageLoader;
25
+ className?: string;
26
+ }
27
+
28
+ interface ArticleVideoViewProps extends ArticleVideoProps {
29
+ playing: boolean;
30
+ onPlay: () => void;
31
+ }
32
+
33
+ const PLAYER_ALLOW =
34
+ "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
35
+
36
+ /**
37
+ * Stateless markup for the article video. Before `playing`, it is a link to
38
+ * the YouTube watch page showing the article's own poster, so nothing is
39
+ * requested from YouTube. Once playing, it renders the privacy-enhanced player.
40
+ */
41
+ export function ArticleVideoView({
42
+ video,
43
+ posterSrc,
44
+ posterAlt,
45
+ posterLoader,
46
+ className,
47
+ playing,
48
+ onPlay,
49
+ }: ArticleVideoViewProps) {
50
+ if (!isYouTubeVideoId(video.id)) {
51
+ return null;
52
+ }
53
+
54
+ const frameClassName =
55
+ "relative aspect-video w-full overflow-hidden rounded-3xl border border-[var(--blog-accent-border)] bg-black shadow-[0_24px_80px_rgb(168_85_247_/_0.14)]";
56
+
57
+ return (
58
+ <div className={cn("article-video", className)}>
59
+ {playing ? (
60
+ <div className={frameClassName}>
61
+ <iframe
62
+ src={buildYouTubeEmbedUrl(video.id)}
63
+ title={video.title}
64
+ allow={PLAYER_ALLOW}
65
+ allowFullScreen
66
+ referrerPolicy="strict-origin-when-cross-origin"
67
+ className="absolute inset-0 h-full w-full border-0"
68
+ />
69
+ </div>
70
+ ) : (
71
+ <a
72
+ href={buildYouTubeWatchUrl(video.id)}
73
+ aria-label={`Play video: ${video.title}`}
74
+ onClick={(event) => {
75
+ if (
76
+ event.button !== 0 ||
77
+ event.metaKey ||
78
+ event.ctrlKey ||
79
+ event.shiftKey ||
80
+ event.altKey
81
+ ) {
82
+ return;
83
+ }
84
+ event.preventDefault();
85
+ onPlay();
86
+ }}
87
+ className={cn(
88
+ frameClassName,
89
+ "group block focus-visible:ring-2 focus-visible:ring-[var(--blog-text-accent)] focus-visible:ring-offset-2 focus-visible:outline-none",
90
+ )}
91
+ >
92
+ {posterSrc ? (
93
+ <Image
94
+ src={posterSrc}
95
+ alt={posterAlt ?? ""}
96
+ {...(posterLoader && { loader: posterLoader })}
97
+ fill
98
+ sizes="(min-width: 896px) 896px, 100vw"
99
+ className="object-cover transition-opacity group-hover:opacity-90"
100
+ priority
101
+ />
102
+ ) : (
103
+ <span
104
+ aria-hidden="true"
105
+ className="absolute inset-0 bg-gradient-to-br from-[var(--blog-accent-bg)] to-black"
106
+ />
107
+ )}
108
+ <span
109
+ aria-hidden="true"
110
+ className="absolute inset-0 flex items-center justify-center bg-black/10 transition-colors group-hover:bg-black/25"
111
+ >
112
+ <span className="flex h-16 w-16 items-center justify-center rounded-full bg-white/95 text-black shadow-xl transition-transform group-hover:scale-105 sm:h-20 sm:w-20">
113
+ <Play className="ml-1 h-7 w-7 fill-current sm:h-9 sm:w-9" />
114
+ </span>
115
+ </span>
116
+ </a>
117
+ )}
118
+ <p className="text-muted-foreground mt-2 text-center text-xs">
119
+ Plays from YouTube
120
+ </p>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ /** Click-to-play YouTube video for the top of an article. */
126
+ export function ArticleVideo(props: ArticleVideoProps) {
127
+ const [playing, setPlaying] = React.useState(false);
128
+
129
+ return (
130
+ <ArticleVideoView
131
+ {...props}
132
+ playing={playing}
133
+ onPlay={() => setPlaying(true)}
134
+ />
135
+ );
136
+ }
@@ -164,6 +164,31 @@ interface PublishedSeriesContext {
164
164
  /** Ordered article list for compact navigation and archive pages */
165
165
  readonly articles: readonly PublishedSeriesArticleLink[];
166
166
  }
167
+ /**
168
+ * Optional video shown at the top of an article.
169
+ *
170
+ * Only YouTube is supported. The page renders a click-to-play preview and
171
+ * loads the privacy-enhanced player only after the reader presses play.
172
+ */
173
+ interface PublishedArticleVideo {
174
+ /** Video host. Only "youtube" is supported. */
175
+ readonly provider: "youtube";
176
+ /** 11-character YouTube video id of the landscape video */
177
+ readonly id: string;
178
+ /** Video title, as on YouTube (used for the player label and VideoObject) */
179
+ readonly title: string;
180
+ /** Optional video description (VideoObject falls back to the article excerpt) */
181
+ readonly description?: string;
182
+ /** Upload date as an ISO 8601 date or date-time */
183
+ readonly uploadDate: string;
184
+ /** Optional ISO 8601 duration, e.g. "PT4M13S" */
185
+ readonly duration?: string;
186
+ /**
187
+ * Reserved: YouTube id of a vertical (Shorts) cut of the same video.
188
+ * Validated and stored, but not rendered yet.
189
+ */
190
+ readonly verticalId?: string;
191
+ }
167
192
  /**
168
193
  * Represents a published article with all necessary metadata for rendering
169
194
  * on external blog platforms.
@@ -197,6 +222,8 @@ interface PublishedArticle {
197
222
  readonly topics?: readonly PublishedTopic[];
198
223
  /** Optional ordered series context for article navigation */
199
224
  readonly series?: PublishedSeriesContext;
225
+ /** Optional video shown in place of the featured image at the top */
226
+ readonly video?: PublishedArticleVideo;
200
227
  /** Featured image URL (optional) */
201
228
  readonly featuredImage?: string;
202
229
  /** Alt text for featured image */
@@ -270,4 +297,4 @@ interface PublishResult {
270
297
  readonly idempotencyKey: string;
271
298
  }
272
299
 
273
- export type { PublishedTopic as P, PublishedArticle as a, PublishedSite as b, PublishedAuthor as c, PublishedNewsletterSettings as d, PublishedArticleCta as e, PublishResult as f, PublishedSeriesContext as g, PublishPayload as h, PublishedAuthorSocialLinks as i };
300
+ export type { PublishedTopic as P, PublishedArticle as a, PublishedSite as b, PublishedAuthor as c, PublishedNewsletterSettings as d, PublishedArticleCta as e, PublishResult as f, PublishedSeriesContext as g, PublishPayload as h, PublishedArticleVideo as i, PublishedAuthorSocialLinks as j };
@@ -164,6 +164,31 @@ interface PublishedSeriesContext {
164
164
  /** Ordered article list for compact navigation and archive pages */
165
165
  readonly articles: readonly PublishedSeriesArticleLink[];
166
166
  }
167
+ /**
168
+ * Optional video shown at the top of an article.
169
+ *
170
+ * Only YouTube is supported. The page renders a click-to-play preview and
171
+ * loads the privacy-enhanced player only after the reader presses play.
172
+ */
173
+ interface PublishedArticleVideo {
174
+ /** Video host. Only "youtube" is supported. */
175
+ readonly provider: "youtube";
176
+ /** 11-character YouTube video id of the landscape video */
177
+ readonly id: string;
178
+ /** Video title, as on YouTube (used for the player label and VideoObject) */
179
+ readonly title: string;
180
+ /** Optional video description (VideoObject falls back to the article excerpt) */
181
+ readonly description?: string;
182
+ /** Upload date as an ISO 8601 date or date-time */
183
+ readonly uploadDate: string;
184
+ /** Optional ISO 8601 duration, e.g. "PT4M13S" */
185
+ readonly duration?: string;
186
+ /**
187
+ * Reserved: YouTube id of a vertical (Shorts) cut of the same video.
188
+ * Validated and stored, but not rendered yet.
189
+ */
190
+ readonly verticalId?: string;
191
+ }
167
192
  /**
168
193
  * Represents a published article with all necessary metadata for rendering
169
194
  * on external blog platforms.
@@ -197,6 +222,8 @@ interface PublishedArticle {
197
222
  readonly topics?: readonly PublishedTopic[];
198
223
  /** Optional ordered series context for article navigation */
199
224
  readonly series?: PublishedSeriesContext;
225
+ /** Optional video shown in place of the featured image at the top */
226
+ readonly video?: PublishedArticleVideo;
200
227
  /** Featured image URL (optional) */
201
228
  readonly featuredImage?: string;
202
229
  /** Alt text for featured image */
@@ -270,4 +297,4 @@ interface PublishResult {
270
297
  readonly idempotencyKey: string;
271
298
  }
272
299
 
273
- export type { PublishedTopic as P, PublishedArticle as a, PublishedSite as b, PublishedAuthor as c, PublishedNewsletterSettings as d, PublishedArticleCta as e, PublishResult as f, PublishedSeriesContext as g, PublishPayload as h, PublishedAuthorSocialLinks as i };
300
+ export type { PublishedTopic as P, PublishedArticle as a, PublishedSite as b, PublishedAuthor as c, PublishedNewsletterSettings as d, PublishedArticleCta as e, PublishResult as f, PublishedSeriesContext as g, PublishPayload as h, PublishedArticleVideo as i, PublishedAuthorSocialLinks as j };
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { a as PublishedArticle, b as PublishedSite, d as PublishedNewsletterSettings, e as PublishedArticleCta, c as PublishedAuthor, f as PublishResult, P as PublishedTopic, g as PublishedSeriesContext } from './index-DsXjuPn4.mjs';
2
- export { h as PublishPayload, i as PublishedAuthorSocialLinks } from './index-DsXjuPn4.mjs';
1
+ import { a as PublishedArticle, b as PublishedSite, d as PublishedNewsletterSettings, e as PublishedArticleCta, c as PublishedAuthor, f as PublishResult, P as PublishedTopic, g as PublishedSeriesContext } from './index-CmjKzrH-.mjs';
2
+ export { h as PublishPayload, i as PublishedArticleVideo, j as PublishedAuthorSocialLinks } from './index-CmjKzrH-.mjs';
3
3
  import * as React from 'react';
4
4
  import { CSSProperties, ReactElement } from 'react';
5
5
  import * as react_jsx_runtime from 'react/jsx-runtime';
@@ -10,8 +10,8 @@ export { RelatedArticleCandidate, RelatedFeaturedArticlesOptions, getRelatedFeat
10
10
  import { NextRequest, NextResponse } from 'next/server.js';
11
11
  import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
12
12
  import { LibSQLDatabase } from 'drizzle-orm/libsql';
13
- import { C as CanonicalUrlOptions } from './sitemap-Dy7Q4ATM.mjs';
14
- export { B as BlogSitemapConfig, N as NextjsSitemapEntry, S as SitemapChangeFreq, a as SitemapData, b as SitemapEntry, c as SitemapOptions, d as createBlogSitemapRoute, e as createDynamicBlogSitemap, g as generateBlogSitemapEntries, f as generateCanonicalUrl, h as generateCanonicalUrlFromSlug, i as generateSitemap, j as generateSitemapIndex, k as getBaseUrl, l as isValidCanonicalUrl, n as normalizeDomain, m as normalizeSlug, s as sitemapToXml, u as updateArticleCanonicalUrl, v as validateSitemap } from './sitemap-Dy7Q4ATM.mjs';
13
+ import { C as CanonicalUrlOptions } from './sitemap-D1eInRA1.mjs';
14
+ export { B as BlogSitemapConfig, N as NextjsSitemapEntry, S as SitemapChangeFreq, a as SitemapData, b as SitemapEntry, c as SitemapOptions, d as createBlogSitemapRoute, e as createDynamicBlogSitemap, g as generateBlogSitemapEntries, f as generateCanonicalUrl, h as generateCanonicalUrlFromSlug, i as generateSitemap, j as generateSitemapIndex, k as getBaseUrl, l as isValidCanonicalUrl, n as normalizeDomain, m as normalizeSlug, s as sitemapToXml, u as updateArticleCanonicalUrl, v as validateSitemap } from './sitemap-D1eInRA1.mjs';
15
15
 
16
16
  interface BlogTheme {
17
17
  accentColor?: string;
@@ -452,6 +452,34 @@ declare function getAllSeries(articles: PublishedArticle[]): PublishedSeriesCont
452
452
  declare function getSeriesArchive(articles: PublishedArticle[], seriesSlug: string): SeriesArchive | null;
453
453
  declare function getArticleSeriesNavigation(article: PublishedArticle): PublishedSeriesContext | null;
454
454
 
455
+ /**
456
+ * Helpers for article videos (YouTube only).
457
+ *
458
+ * Every URL built here goes through `isYouTubeVideoId` first, so a stored id
459
+ * can never inject markup or a different host into an embed.
460
+ */
461
+ declare const ARTICLE_VIDEO_TITLE_MAX_LENGTH = 200;
462
+ declare const ARTICLE_VIDEO_DESCRIPTION_MAX_LENGTH = 5000;
463
+ declare function isYouTubeVideoId(value: unknown): value is string;
464
+ declare function isIsoDuration(value: unknown): value is string;
465
+ /**
466
+ * Extract a YouTube video id from a bare id or a YouTube URL
467
+ * (watch, youtu.be, embed, nocookie embed, shorts, live).
468
+ */
469
+ declare function parseYouTubeVideoId(input: string): string | null;
470
+ /**
471
+ * Validate an untrusted `video` value from a publish payload.
472
+ * Returns a list of human-readable errors; empty when valid.
473
+ */
474
+ declare function validatePublishedArticleVideo(value: unknown): string[];
475
+ /** Privacy-enhanced player URL, loaded only after the reader presses play. */
476
+ declare function buildYouTubeEmbedUrl(id: string): string;
477
+ /** Canonical player URL for structured data. */
478
+ declare function buildYouTubeStructuredDataEmbedUrl(id: string): string;
479
+ declare function buildYouTubeWatchUrl(id: string): string;
480
+ /** Thumbnail URL for structured data only; the page never requests it. */
481
+ declare function buildYouTubeThumbnailUrl(id: string): string;
482
+
455
483
  interface BlogTopicsIndexPageProps {
456
484
  articles: PublishedArticle[];
457
485
  basePath?: string;
@@ -2048,6 +2076,20 @@ interface BlogPostingSchema extends BaseStructuredData {
2048
2076
  "@type": "Thing";
2049
2077
  name: string;
2050
2078
  }[];
2079
+ video?: VideoObjectSchema;
2080
+ }
2081
+ /**
2082
+ * VideoObject schema for an article's embedded video
2083
+ * @see https://schema.org/VideoObject
2084
+ */
2085
+ interface VideoObjectSchema {
2086
+ "@type": "VideoObject";
2087
+ name: string;
2088
+ description: string;
2089
+ thumbnailUrl: string[];
2090
+ uploadDate: string;
2091
+ duration?: string;
2092
+ embedUrl: string;
2051
2093
  }
2052
2094
  /**
2053
2095
  * WebSite schema for the blog home page
@@ -2103,6 +2145,10 @@ interface StructuredDataOptions {
2103
2145
  * Generate comprehensive structured data for a blog article
2104
2146
  */
2105
2147
  declare function generateArticleStructuredData(article: PublishedArticle, options?: StructuredDataOptions): BlogPostingSchema;
2148
+ /**
2149
+ * Generate the VideoObject for an article's video, if it has a valid one
2150
+ */
2151
+ declare function generateVideoStructuredData(article: PublishedArticle): VideoObjectSchema | undefined;
2106
2152
  /**
2107
2153
  * Generate structured data for an author
2108
2154
  */
@@ -2283,5 +2329,5 @@ declare function OPTIONS(request: NextRequest): Promise<NextResponse>;
2283
2329
 
2284
2330
  declare const PACKAGE_VERSION: string;
2285
2331
 
2286
- export { ApiKeyAuth, BLOG_PLATFORM_SCHEMA_VERSION, BLOG_PLATFORM_TABLE_NAMES, ArticlePage as BlogArticlePage, AuthorPage as BlogAuthorPage, BlogAuthorsIndexPage, BlogHomePage, BlogPlatformSchemaError, BlogSeriesArchivePage, BlogSeriesArchiveRoutePage, BlogSeriesIndexPage, BlogSeriesPage, BlogTopicArchivePage, BlogTopicsIndexPage, Button, CanonicalUrlOptions, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, DrizzleStorage, EnvAuth, InMemoryStorage, NEWSLETTER_SUCCESS_MESSAGE, NewsletterSignup, NoAuth, PACKAGE_VERSION, Pagination$1 as Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, PublishResult, PublishedArticle, PublishedAuthor, PublishedNewsletterSettings, PublishedSite, PublishedTopic, SeriesNavigation, Pagination as ShadcnPagination, StructuredDataError, UninitializedAuth, autoConfigureRateLimit, blogPlatformDrizzleSchema, bpArticles, bpMetadata, bpOperations, buildAuthorPath, buildBlogPath, buildSeriesPath, buttonVariants, checkDrizzleAdapterSchema, checkRateLimit, cn, POST as configHandler, OPTIONS$1 as configOptionsHandler, configureRateLimit, createRateLimitErrorResponse, createStructuredDataScript, createTokenFingerprint, enforceRateLimit, ensureDrizzleAdapterSchema, extractKeywordsFromArticle, extractTableOfContents, extractTokenFromHeader, formatArticleDateMeta, generateArticleMetadata, generateArticleStructuredData, generateAuthorPageMetadata, generateAuthorStructuredData, generateBlogHomeMetadata, generateBlogHomeStructuredData, generateBreadcrumbStructuredData, generateCompleteArticleStructuredData, generateOrganizationStructuredData, generatePaginationMetadata, generateSeriesArchiveMetadata, generateSeriesIndexMetadata, generateSiteMetadata, generateWebSiteStructuredData, getAllSeries, getAllTopics, getArticlePrimaryTopic, getArticleSeriesNavigation, getAuthConfig, getAuthorArchive, getAuthorsFromArticles, getBlogThemeStyle, getDrizzleAdapterSql, getPathPrefixFromBlogUrl, getRateLimitHeaders, getSeriesArchive, getStorage, getTopicArchive, GET$1 as indexNowKeyRoute, initBlogPlatform, initBlogPlatformDev, initBlogPlatformOnce, initBlogPlatformProd, initEnvAuth, loadAuthorArchive, metadataToHtml, normalizeBlogBasePath, POST$2 as publishHandler, safeGenerateArticleStructuredData, setAuthConfig, setStorage, shouldSkipAuth, GET as slugExistsHandler, OPTIONS as slugExistsOptionsHandler, structuredDataToJsonLd, POST$1 as unpublishHandler, usePagination, validateMetadata, validateStructuredData, GET$2 as versionHandler };
2287
- export type { AuthConfig, AuthResult, AuthorArchive, AuthorArchiveOptions, BlogPlatformConfig, BlogPlatformSchemaCheckResult, BlogPlatformStorage, BlogPlatformTableName, BlogPostingSchema, BlogTheme, BreadcrumbListSchema, GeneratedMetadata, LinkTag, MetaTag, MetadataOptions, NewsletterSignupProps, OrganizationSchema, PersonSchema, RateLimitConfig, RateLimitResult, SeriesArchive, StructuredDataOptions, TopicArchive, WebSiteSchema };
2332
+ export { ARTICLE_VIDEO_DESCRIPTION_MAX_LENGTH, ARTICLE_VIDEO_TITLE_MAX_LENGTH, ApiKeyAuth, BLOG_PLATFORM_SCHEMA_VERSION, BLOG_PLATFORM_TABLE_NAMES, ArticlePage as BlogArticlePage, AuthorPage as BlogAuthorPage, BlogAuthorsIndexPage, BlogHomePage, BlogPlatformSchemaError, BlogSeriesArchivePage, BlogSeriesArchiveRoutePage, BlogSeriesIndexPage, BlogSeriesPage, BlogTopicArchivePage, BlogTopicsIndexPage, Button, CanonicalUrlOptions, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, DrizzleStorage, EnvAuth, InMemoryStorage, NEWSLETTER_SUCCESS_MESSAGE, NewsletterSignup, NoAuth, PACKAGE_VERSION, Pagination$1 as Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, PublishResult, PublishedArticle, PublishedAuthor, PublishedNewsletterSettings, PublishedSite, PublishedTopic, SeriesNavigation, Pagination as ShadcnPagination, StructuredDataError, UninitializedAuth, autoConfigureRateLimit, blogPlatformDrizzleSchema, bpArticles, bpMetadata, bpOperations, buildAuthorPath, buildBlogPath, buildSeriesPath, buildYouTubeEmbedUrl, buildYouTubeStructuredDataEmbedUrl, buildYouTubeThumbnailUrl, buildYouTubeWatchUrl, buttonVariants, checkDrizzleAdapterSchema, checkRateLimit, cn, POST as configHandler, OPTIONS$1 as configOptionsHandler, configureRateLimit, createRateLimitErrorResponse, createStructuredDataScript, createTokenFingerprint, enforceRateLimit, ensureDrizzleAdapterSchema, extractKeywordsFromArticle, extractTableOfContents, extractTokenFromHeader, formatArticleDateMeta, generateArticleMetadata, generateArticleStructuredData, generateAuthorPageMetadata, generateAuthorStructuredData, generateBlogHomeMetadata, generateBlogHomeStructuredData, generateBreadcrumbStructuredData, generateCompleteArticleStructuredData, generateOrganizationStructuredData, generatePaginationMetadata, generateSeriesArchiveMetadata, generateSeriesIndexMetadata, generateSiteMetadata, generateVideoStructuredData, generateWebSiteStructuredData, getAllSeries, getAllTopics, getArticlePrimaryTopic, getArticleSeriesNavigation, getAuthConfig, getAuthorArchive, getAuthorsFromArticles, getBlogThemeStyle, getDrizzleAdapterSql, getPathPrefixFromBlogUrl, getRateLimitHeaders, getSeriesArchive, getStorage, getTopicArchive, GET$1 as indexNowKeyRoute, initBlogPlatform, initBlogPlatformDev, initBlogPlatformOnce, initBlogPlatformProd, initEnvAuth, isIsoDuration, isYouTubeVideoId, loadAuthorArchive, metadataToHtml, normalizeBlogBasePath, parseYouTubeVideoId, POST$2 as publishHandler, safeGenerateArticleStructuredData, setAuthConfig, setStorage, shouldSkipAuth, GET as slugExistsHandler, OPTIONS as slugExistsOptionsHandler, structuredDataToJsonLd, POST$1 as unpublishHandler, usePagination, validateMetadata, validatePublishedArticleVideo, validateStructuredData, GET$2 as versionHandler };
2333
+ export type { AuthConfig, AuthResult, AuthorArchive, AuthorArchiveOptions, BlogPlatformConfig, BlogPlatformSchemaCheckResult, BlogPlatformStorage, BlogPlatformTableName, BlogPostingSchema, BlogTheme, BreadcrumbListSchema, GeneratedMetadata, LinkTag, MetaTag, MetadataOptions, NewsletterSignupProps, OrganizationSchema, PersonSchema, RateLimitConfig, RateLimitResult, SeriesArchive, StructuredDataOptions, TopicArchive, VideoObjectSchema, WebSiteSchema };