@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.
@@ -100,6 +100,7 @@ function toRow(article: PublishedArticle): ArticleRow {
100
100
  __bloggyTopics: article.topics ?? [],
101
101
  __bloggySeries: article.series ?? null,
102
102
  __bloggyCta: article.cta ?? null,
103
+ ...(article.video && { __bloggyVideo: article.video }),
103
104
  }),
104
105
  version: article.version,
105
106
  };
@@ -128,6 +129,7 @@ function fromRow(row: ArticleRow): PublishedArticle {
128
129
  cta: extractCta(row.metaJson),
129
130
  topics: extractTopics(row.metaJson),
130
131
  series: extractSeries(row.metaJson),
132
+ ...withVideo(row.metaJson),
131
133
  meta: extractMeta(row.metaJson),
132
134
  version: row.version,
133
135
  };
@@ -137,6 +139,7 @@ type StoredArticleMeta = PublishedArticle["meta"] & {
137
139
  __bloggyTopics?: PublishedArticle["topics"];
138
140
  __bloggySeries?: PublishedArticle["series"] | null;
139
141
  __bloggyCta?: PublishedArticle["cta"] | null;
142
+ __bloggyVideo?: PublishedArticle["video"];
140
143
  };
141
144
 
142
145
  function parseStoredMeta(metaJson: string): StoredArticleMeta {
@@ -158,8 +161,14 @@ function extractSeries(metaJson: string): PublishedArticle["series"] {
158
161
  return parsed.__bloggySeries ?? undefined;
159
162
  }
160
163
 
164
+ function withVideo(metaJson: string): Pick<PublishedArticle, "video"> {
165
+ const video = parseStoredMeta(metaJson).__bloggyVideo;
166
+ return video ? { video } : {};
167
+ }
168
+
161
169
  function extractMeta(metaJson: string): PublishedArticle["meta"] {
162
170
  const parsed = parseStoredMeta(metaJson);
171
+ delete parsed.__bloggyVideo;
163
172
  delete parsed.__bloggyTopics;
164
173
  delete parsed.__bloggySeries;
165
174
  delete parsed.__bloggyCta;
@@ -15,6 +15,11 @@ import type {
15
15
  PublishedSite,
16
16
  } from "../types/contracts";
17
17
  import { buildAuthorPath } from "./urls";
18
+ import {
19
+ buildYouTubeStructuredDataEmbedUrl,
20
+ buildYouTubeThumbnailUrl,
21
+ isYouTubeVideoId,
22
+ } from "./video";
18
23
 
19
24
  /**
20
25
  * Base schema.org context and type
@@ -95,6 +100,21 @@ export interface BlogPostingSchema extends BaseStructuredData {
95
100
  "@type": "Thing";
96
101
  name: string;
97
102
  }[];
103
+ video?: VideoObjectSchema;
104
+ }
105
+
106
+ /**
107
+ * VideoObject schema for an article's embedded video
108
+ * @see https://schema.org/VideoObject
109
+ */
110
+ export interface VideoObjectSchema {
111
+ "@type": "VideoObject";
112
+ name: string;
113
+ description: string;
114
+ thumbnailUrl: string[];
115
+ uploadDate: string;
116
+ duration?: string;
117
+ embedUrl: string;
98
118
  }
99
119
 
100
120
  /**
@@ -205,6 +225,11 @@ export function generateArticleStructuredData(
205
225
  isAccessibleForFree,
206
226
  };
207
227
 
228
+ const videoSchema = generateVideoStructuredData(article);
229
+ if (videoSchema) {
230
+ schema.video = videoSchema;
231
+ }
232
+
208
233
  // Add optional properties if enabled
209
234
  if (includeOptionalProperties) {
210
235
  // Featured image
@@ -239,6 +264,33 @@ export function generateArticleStructuredData(
239
264
  return schema;
240
265
  }
241
266
 
267
+ /**
268
+ * Generate the VideoObject for an article's video, if it has a valid one
269
+ */
270
+ export function generateVideoStructuredData(
271
+ article: PublishedArticle,
272
+ ): VideoObjectSchema | undefined {
273
+ const video = article.video;
274
+ if (!video || !isYouTubeVideoId(video.id)) {
275
+ return undefined;
276
+ }
277
+
278
+ const description = video.description?.trim() ?? "";
279
+
280
+ return {
281
+ "@type": "VideoObject",
282
+ name: video.title,
283
+ description: description.length > 0 ? description : article.excerpt,
284
+ thumbnailUrl: [
285
+ buildYouTubeThumbnailUrl(video.id),
286
+ ...(article.featuredImage ? [article.featuredImage] : []),
287
+ ],
288
+ uploadDate: video.uploadDate,
289
+ ...(video.duration && { duration: video.duration }),
290
+ embedUrl: buildYouTubeStructuredDataEmbedUrl(video.id),
291
+ };
292
+ }
293
+
242
294
  /**
243
295
  * Generate structured data for an author
244
296
  */
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Helpers for article videos (YouTube only).
3
+ *
4
+ * Every URL built here goes through `isYouTubeVideoId` first, so a stored id
5
+ * can never inject markup or a different host into an embed.
6
+ */
7
+
8
+ const YOUTUBE_VIDEO_ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
9
+ const ISO_DURATION_PATTERN = /^PT(?=\d)(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?$/;
10
+ const ISO_DATE_PREFIX_PATTERN = /^\d{4}-\d{2}-\d{2}(?:$|T)/;
11
+ const YOUTUBE_HOSTS = new Set([
12
+ "youtube.com",
13
+ "www.youtube.com",
14
+ "m.youtube.com",
15
+ "music.youtube.com",
16
+ "youtube-nocookie.com",
17
+ "www.youtube-nocookie.com",
18
+ "youtu.be",
19
+ ]);
20
+ const YOUTUBE_PATH_PREFIXES = ["embed", "shorts", "live", "v"];
21
+
22
+ export const ARTICLE_VIDEO_TITLE_MAX_LENGTH = 200;
23
+ export const ARTICLE_VIDEO_DESCRIPTION_MAX_LENGTH = 5000;
24
+
25
+ export function isYouTubeVideoId(value: unknown): value is string {
26
+ return typeof value === "string" && YOUTUBE_VIDEO_ID_PATTERN.test(value);
27
+ }
28
+
29
+ export function isIsoDuration(value: unknown): value is string {
30
+ return (
31
+ typeof value === "string" &&
32
+ value !== "PT" &&
33
+ ISO_DURATION_PATTERN.test(value)
34
+ );
35
+ }
36
+
37
+ /** ISO 8601 date or date-time whose calendar date exists (no 31 February). */
38
+ function isIsoDate(value: unknown): value is string {
39
+ if (typeof value !== "string" || !ISO_DATE_PREFIX_PATTERN.test(value)) {
40
+ return false;
41
+ }
42
+ const datePart = value.slice(0, 10);
43
+ const parsed = new Date(`${datePart}T00:00:00Z`);
44
+ return (
45
+ !Number.isNaN(parsed.getTime()) &&
46
+ parsed.toISOString().slice(0, 10) === datePart &&
47
+ !Number.isNaN(Date.parse(value))
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Extract a YouTube video id from a bare id or a YouTube URL
53
+ * (watch, youtu.be, embed, nocookie embed, shorts, live).
54
+ */
55
+ export function parseYouTubeVideoId(input: string): string | null {
56
+ const trimmed = input.trim();
57
+ if (YOUTUBE_VIDEO_ID_PATTERN.test(trimmed)) return trimmed;
58
+ if (!trimmed) return null;
59
+
60
+ let url: URL;
61
+ try {
62
+ url = new URL(
63
+ /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`,
64
+ );
65
+ } catch {
66
+ return null;
67
+ }
68
+
69
+ const host = url.hostname.toLowerCase();
70
+ if (!YOUTUBE_HOSTS.has(host)) return null;
71
+
72
+ const segments = url.pathname.split("/").filter(Boolean);
73
+ let candidate: string | null | undefined;
74
+ if (host === "youtu.be") {
75
+ candidate = segments[0];
76
+ } else if (segments[0] === "watch") {
77
+ candidate = url.searchParams.get("v");
78
+ } else if (segments[0] && YOUTUBE_PATH_PREFIXES.includes(segments[0])) {
79
+ candidate = segments[1];
80
+ }
81
+
82
+ return isYouTubeVideoId(candidate) ? candidate : null;
83
+ }
84
+
85
+ /**
86
+ * Validate an untrusted `video` value from a publish payload.
87
+ * Returns a list of human-readable errors; empty when valid.
88
+ */
89
+ export function validatePublishedArticleVideo(value: unknown): string[] {
90
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
91
+ return ["Article video must be an object"];
92
+ }
93
+
94
+ const video = value as Record<string, unknown>;
95
+ const errors: string[] = [];
96
+
97
+ if (video.provider !== "youtube") {
98
+ errors.push('Article video provider must be "youtube"');
99
+ }
100
+ if (!isYouTubeVideoId(video.id)) {
101
+ errors.push("Article video id must be an 11-character YouTube id");
102
+ }
103
+ if (typeof video.title !== "string" || !video.title.trim()) {
104
+ errors.push("Article video title is required");
105
+ } else if (video.title.length > ARTICLE_VIDEO_TITLE_MAX_LENGTH) {
106
+ errors.push(
107
+ `Article video title must be ${ARTICLE_VIDEO_TITLE_MAX_LENGTH} characters or fewer`,
108
+ );
109
+ }
110
+ if (!isIsoDate(video.uploadDate)) {
111
+ errors.push("Article video uploadDate must be an ISO 8601 date");
112
+ }
113
+ if (video.duration !== undefined && !isIsoDuration(video.duration)) {
114
+ errors.push(
115
+ "Article video duration must be an ISO 8601 duration such as PT4M13S",
116
+ );
117
+ }
118
+ if (video.verticalId !== undefined && !isYouTubeVideoId(video.verticalId)) {
119
+ errors.push("Article video verticalId must be an 11-character YouTube id");
120
+ }
121
+ if (video.description !== undefined) {
122
+ if (typeof video.description !== "string") {
123
+ errors.push("Article video description must be a string");
124
+ } else if (
125
+ video.description.length > ARTICLE_VIDEO_DESCRIPTION_MAX_LENGTH
126
+ ) {
127
+ errors.push(
128
+ `Article video description must be ${ARTICLE_VIDEO_DESCRIPTION_MAX_LENGTH} characters or fewer`,
129
+ );
130
+ }
131
+ }
132
+
133
+ return errors;
134
+ }
135
+
136
+ function assertYouTubeVideoId(id: string): void {
137
+ if (!isYouTubeVideoId(id)) {
138
+ throw new Error("Invalid YouTube video id");
139
+ }
140
+ }
141
+
142
+ /** Privacy-enhanced player URL, loaded only after the reader presses play. */
143
+ export function buildYouTubeEmbedUrl(id: string): string {
144
+ assertYouTubeVideoId(id);
145
+ return `https://www.youtube-nocookie.com/embed/${id}?rel=0&autoplay=1`;
146
+ }
147
+
148
+ /** Canonical player URL for structured data. */
149
+ export function buildYouTubeStructuredDataEmbedUrl(id: string): string {
150
+ assertYouTubeVideoId(id);
151
+ return `https://www.youtube.com/embed/${id}`;
152
+ }
153
+
154
+ export function buildYouTubeWatchUrl(id: string): string {
155
+ assertYouTubeVideoId(id);
156
+ return `https://www.youtube.com/watch?v=${id}`;
157
+ }
158
+
159
+ /** Thumbnail URL for structured data only; the page never requests it. */
160
+ export function buildYouTubeThumbnailUrl(id: string): string {
161
+ assertYouTubeVideoId(id);
162
+ return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
163
+ }
@@ -1,4 +1,4 @@
1
- import { a as PublishedArticle, b as PublishedSite, c as PublishedAuthor } from './index-DsXjuPn4.js';
1
+ import { a as PublishedArticle, b as PublishedSite, c as PublishedAuthor } from './index-CmjKzrH-.js';
2
2
 
3
3
  /**
4
4
  * SEO utilities for canonical URL generation and validation.
@@ -1,4 +1,4 @@
1
- import { a as PublishedArticle, b as PublishedSite, c as PublishedAuthor } from './index-DsXjuPn4.mjs';
1
+ import { a as PublishedArticle, b as PublishedSite, c as PublishedAuthor } from './index-CmjKzrH-.mjs';
2
2
 
3
3
  /**
4
4
  * SEO utilities for canonical URL generation and validation.
@@ -1,2 +1,2 @@
1
- import './index-DsXjuPn4.mjs';
2
- 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, i as generateSitemap, j as generateSitemapIndex, s as sitemapToXml, v as validateSitemap } from './sitemap-Dy7Q4ATM.mjs';
1
+ import './index-CmjKzrH-.mjs';
2
+ 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, i as generateSitemap, j as generateSitemapIndex, s as sitemapToXml, v as validateSitemap } from './sitemap-D1eInRA1.mjs';
package/dist/sitemap.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- import './index-DsXjuPn4.js';
2
- 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, i as generateSitemap, j as generateSitemapIndex, s as sitemapToXml, v as validateSitemap } from './sitemap-B64QGT4l.js';
1
+ import './index-CmjKzrH-.js';
2
+ 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, i as generateSitemap, j as generateSitemapIndex, s as sitemapToXml, v as validateSitemap } from './sitemap-CEjqxRaQ.js';
@@ -221,6 +221,38 @@ export interface PublishedSeriesContext {
221
221
  readonly articles: readonly PublishedSeriesArticleLink[];
222
222
  }
223
223
 
224
+ /**
225
+ * Optional video shown at the top of an article.
226
+ *
227
+ * Only YouTube is supported. The page renders a click-to-play preview and
228
+ * loads the privacy-enhanced player only after the reader presses play.
229
+ */
230
+ export interface PublishedArticleVideo {
231
+ /** Video host. Only "youtube" is supported. */
232
+ readonly provider: "youtube";
233
+
234
+ /** 11-character YouTube video id of the landscape video */
235
+ readonly id: string;
236
+
237
+ /** Video title, as on YouTube (used for the player label and VideoObject) */
238
+ readonly title: string;
239
+
240
+ /** Optional video description (VideoObject falls back to the article excerpt) */
241
+ readonly description?: string;
242
+
243
+ /** Upload date as an ISO 8601 date or date-time */
244
+ readonly uploadDate: string;
245
+
246
+ /** Optional ISO 8601 duration, e.g. "PT4M13S" */
247
+ readonly duration?: string;
248
+
249
+ /**
250
+ * Reserved: YouTube id of a vertical (Shorts) cut of the same video.
251
+ * Validated and stored, but not rendered yet.
252
+ */
253
+ readonly verticalId?: string;
254
+ }
255
+
224
256
  /**
225
257
  * Represents a published article with all necessary metadata for rendering
226
258
  * on external blog platforms.
@@ -268,6 +300,9 @@ export interface PublishedArticle {
268
300
  /** Optional ordered series context for article navigation */
269
301
  readonly series?: PublishedSeriesContext;
270
302
 
303
+ /** Optional video shown in place of the featured image at the top */
304
+ readonly video?: PublishedArticleVideo;
305
+
271
306
  /** Featured image URL (optional) */
272
307
  readonly featuredImage?: string;
273
308
 
@@ -1 +1 @@
1
- export { h as PublishPayload, f as PublishResult, a as PublishedArticle, c as PublishedAuthor, i as PublishedAuthorSocialLinks, d as PublishedNewsletterSettings, b as PublishedSite, P as PublishedTopic } from '../index-DsXjuPn4.mjs';
1
+ export { h as PublishPayload, f as PublishResult, a as PublishedArticle, i as PublishedArticleVideo, c as PublishedAuthor, j as PublishedAuthorSocialLinks, d as PublishedNewsletterSettings, b as PublishedSite, P as PublishedTopic } from '../index-CmjKzrH-.mjs';
@@ -1 +1 @@
1
- export { h as PublishPayload, f as PublishResult, a as PublishedArticle, c as PublishedAuthor, i as PublishedAuthorSocialLinks, d as PublishedNewsletterSettings, b as PublishedSite, P as PublishedTopic } from '../index-DsXjuPn4.js';
1
+ export { h as PublishPayload, f as PublishResult, a as PublishedArticle, i as PublishedArticleVideo, c as PublishedAuthor, j as PublishedAuthorSocialLinks, d as PublishedNewsletterSettings, b as PublishedSite, P as PublishedTopic } from '../index-CmjKzrH-.js';
@@ -7,6 +7,7 @@
7
7
 
8
8
  export type {
9
9
  PublishedArticle,
10
+ PublishedArticleVideo,
10
11
  PublishedAuthor,
11
12
  PublishedAuthorSocialLinks,
12
13
  PublishedNewsletterSettings,
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "@xexr/blog-platform",
3
- "version": "1.1.7",
3
+ "version": "1.2.0",
4
4
  "description": "SEO-friendly blog platform for Next.js 15 with Bloggy integration",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Xexr/bloggy.git",
8
+ "directory": "packages/blog-platform"
9
+ },
5
10
  "main": "./dist/index.js",
6
11
  "types": "./dist/index.d.ts",
7
12
  "exports": {