@xexr/blog-platform 1.1.5 → 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 +13 -0
- package/README.md +21 -0
- package/dist/app/api/blog/publish/route.test.ts +55 -0
- package/dist/app/api/blog/publish/route.ts +7 -2
- package/dist/app/blog/[slug]/page.tsx +41 -28
- package/dist/components/ArchiveStructuredData.tsx +61 -0
- package/dist/components/ArticleVideo.tsx +136 -0
- package/dist/components/SeriesArchivePages.tsx +10 -0
- package/dist/components/TopicArchivePages.test.ts +52 -4
- package/dist/components/TopicArchivePages.tsx +14 -4
- package/dist/{index-CKWrb9a4.d.mts → index-CmjKzrH-.d.mts} +30 -1
- package/dist/{index-CKWrb9a4.d.ts → index-CmjKzrH-.d.ts} +30 -1
- package/dist/index.d.mts +52 -5
- package/dist/index.d.ts +52 -5
- package/dist/index.js +14 -14
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +14 -14
- package/dist/index.mjs.map +1 -1
- package/dist/lib/related-articles.d.mts +3 -2
- package/dist/lib/related-articles.d.ts +3 -2
- package/dist/lib/sitemap.ts +2 -42
- package/dist/lib/storage/drizzle.ts +9 -0
- package/dist/lib/structured-data.ts +62 -11
- package/dist/lib/video.ts +163 -0
- package/dist/{sitemap-BDT3d496.d.ts → sitemap-CEjqxRaQ.d.ts} +5 -4
- package/dist/{sitemap-D3rS0iAF.d.mts → sitemap-D1eInRA1.d.mts} +5 -4
- package/dist/sitemap.d.mts +2 -2
- package/dist/sitemap.d.ts +2 -2
- package/dist/sitemap.js +4 -4
- package/dist/sitemap.js.map +1 -1
- package/dist/sitemap.mjs +4 -4
- package/dist/sitemap.mjs.map +1 -1
- package/dist/types/contracts.ts +38 -0
- package/dist/types/index.d.mts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.ts +1 -0
- package/package.json +17 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
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
|
+
|
|
9
|
+
## 1.1.7
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Correct article subjects and author expertise, add topic and series archive structured data, and omit paginated URLs from sitemaps.
|
|
14
|
+
- 5e14632: Raise the `next` peer dependency range to `^15.5.28 || ^16.4.1`, excluding Next.js releases affected by the October 2026 Next.js security advisories (GHSA-2464-fhr4-w9xw, GHSA-p3m5-59mp-jv5r, GHSA-673q-mjm6-r5xr).
|
|
15
|
+
|
|
3
16
|
## 1.1.1
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
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: "
|
|
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
|
-
{/*
|
|
344
|
-
{
|
|
345
|
-
<figure className="article-featured-
|
|
346
|
-
<
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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,61 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { PublishedArticle } from "../types/contracts";
|
|
3
|
+
import {
|
|
4
|
+
generateBreadcrumbStructuredData,
|
|
5
|
+
structuredDataToJsonLd,
|
|
6
|
+
} from "../lib/structured-data";
|
|
7
|
+
import { buildBlogPath } from "../lib/urls";
|
|
8
|
+
|
|
9
|
+
export function ArchiveStructuredData({
|
|
10
|
+
articles,
|
|
11
|
+
title,
|
|
12
|
+
kind,
|
|
13
|
+
slug,
|
|
14
|
+
basePath,
|
|
15
|
+
}: {
|
|
16
|
+
articles: PublishedArticle[];
|
|
17
|
+
title: string;
|
|
18
|
+
kind: "topics" | "series";
|
|
19
|
+
slug: string;
|
|
20
|
+
basePath: string;
|
|
21
|
+
}) {
|
|
22
|
+
const site = articles[0]?.site;
|
|
23
|
+
if (!site) return null;
|
|
24
|
+
const domain = site.domain;
|
|
25
|
+
const archivePath = buildBlogPath(basePath, `${kind}/${slug}`);
|
|
26
|
+
const collection = {
|
|
27
|
+
"@context": "https://schema.org" as const,
|
|
28
|
+
"@type": "CollectionPage",
|
|
29
|
+
name: title,
|
|
30
|
+
url: `https://${domain}${archivePath}`,
|
|
31
|
+
mainEntity: {
|
|
32
|
+
"@type": "ItemList",
|
|
33
|
+
itemListElement: articles.map((article, index) => ({
|
|
34
|
+
"@type": "ListItem",
|
|
35
|
+
position: index + 1,
|
|
36
|
+
name: article.title,
|
|
37
|
+
url: `https://${domain}${buildBlogPath(basePath, article.slug)}`,
|
|
38
|
+
})),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const breadcrumbs = generateBreadcrumbStructuredData(
|
|
42
|
+
[
|
|
43
|
+
{ name: "Home", url: "/" },
|
|
44
|
+
{ name: site.name, url: basePath },
|
|
45
|
+
{
|
|
46
|
+
name: kind === "topics" ? "Topics" : "Series",
|
|
47
|
+
url: buildBlogPath(basePath, kind),
|
|
48
|
+
},
|
|
49
|
+
{ name: title, url: archivePath },
|
|
50
|
+
],
|
|
51
|
+
{ customDomain: domain },
|
|
52
|
+
);
|
|
53
|
+
return (
|
|
54
|
+
<script
|
|
55
|
+
type="application/ld+json"
|
|
56
|
+
dangerouslySetInnerHTML={{
|
|
57
|
+
__html: structuredDataToJsonLd([collection, breadcrumbs]),
|
|
58
|
+
}}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { ArchiveStructuredData } from "./ArchiveStructuredData";
|
|
2
3
|
import Image from "next/image.js";
|
|
3
4
|
import Link from "next/link.js";
|
|
4
5
|
import { formatDistanceToNow } from "date-fns";
|
|
@@ -122,6 +123,15 @@ export function BlogSeriesArchivePage({
|
|
|
122
123
|
className,
|
|
123
124
|
)}
|
|
124
125
|
>
|
|
126
|
+
{archive && (
|
|
127
|
+
<ArchiveStructuredData
|
|
128
|
+
articles={archive.articles}
|
|
129
|
+
title={archive.series.title}
|
|
130
|
+
kind="series"
|
|
131
|
+
slug={seriesSlug}
|
|
132
|
+
basePath={normalizedBasePath}
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
125
135
|
<header className="mb-10 space-y-3 text-center">
|
|
126
136
|
<Link
|
|
127
137
|
href={buildBlogPath(normalizedBasePath, "series")}
|
|
@@ -2,10 +2,8 @@ import React from "react";
|
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
BlogTopicsIndexPage,
|
|
8
|
-
} from "./TopicArchivePages";
|
|
5
|
+
import { BlogTopicArchivePage, BlogTopicsIndexPage } from "./TopicArchivePages";
|
|
6
|
+
import { BlogSeriesArchivePage } from "./SeriesArchivePages";
|
|
9
7
|
import type { PublishedArticle, PublishedSite } from "../types/contracts";
|
|
10
8
|
|
|
11
9
|
const site: PublishedSite = {
|
|
@@ -99,3 +97,53 @@ describe("Blog topic archive pages", () => {
|
|
|
99
97
|
expect(html).not.toContain("h-full w-full object-contain");
|
|
100
98
|
});
|
|
101
99
|
});
|
|
100
|
+
|
|
101
|
+
function schemas(html: string): unknown[] {
|
|
102
|
+
return [
|
|
103
|
+
...html.matchAll(/<script type="application\/ld\+json">(.*?)<\/script>/g),
|
|
104
|
+
].flatMap((match): unknown[] => JSON.parse(match[1]!) as unknown[]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
it("renders topic archive collection and breadcrumbs for the listed articles", () => {
|
|
108
|
+
const data = schemas(
|
|
109
|
+
renderToStaticMarkup(
|
|
110
|
+
React.createElement(BlogTopicArchivePage, {
|
|
111
|
+
articles: [article],
|
|
112
|
+
topicSlug: "recurring-work-and-routines",
|
|
113
|
+
basePath: "/new-blog",
|
|
114
|
+
}),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
expect(JSON.stringify(data)).toContain(
|
|
118
|
+
"https://example.com/new-blog/useful-routines",
|
|
119
|
+
);
|
|
120
|
+
expect(JSON.stringify(data)).toContain("CollectionPage");
|
|
121
|
+
expect(JSON.stringify(data)).toContain("BreadcrumbList");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("renders series archive collection and breadcrumbs for the listed articles", () => {
|
|
125
|
+
const seriesArticle = {
|
|
126
|
+
...article,
|
|
127
|
+
series: {
|
|
128
|
+
id: "s",
|
|
129
|
+
title: "Routines",
|
|
130
|
+
slug: "routines",
|
|
131
|
+
position: 1,
|
|
132
|
+
total: 1,
|
|
133
|
+
articles: [],
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
const data = schemas(
|
|
137
|
+
renderToStaticMarkup(
|
|
138
|
+
React.createElement(BlogSeriesArchivePage, {
|
|
139
|
+
articles: [seriesArticle],
|
|
140
|
+
seriesSlug: "routines",
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
expect(JSON.stringify(data)).toContain(
|
|
145
|
+
"https://example.com/blog/useful-routines",
|
|
146
|
+
);
|
|
147
|
+
expect(JSON.stringify(data)).toContain("CollectionPage");
|
|
148
|
+
expect(JSON.stringify(data)).toContain("BreadcrumbList");
|
|
149
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { ArchiveStructuredData } from "./ArchiveStructuredData";
|
|
2
3
|
import Link from "next/link.js";
|
|
3
4
|
import Image from "next/image.js";
|
|
4
5
|
import { formatDistanceToNow } from "date-fns";
|
|
@@ -52,7 +53,7 @@ export function BlogTopicsIndexPage({
|
|
|
52
53
|
<header className="mb-10 space-y-3 text-center">
|
|
53
54
|
<Link
|
|
54
55
|
href={normalizedBasePath}
|
|
55
|
-
className="text-muted-foreground hover:text-[var(--blog-accent)]
|
|
56
|
+
className="text-muted-foreground text-sm font-medium transition-colors hover:text-[var(--blog-accent)]"
|
|
56
57
|
>
|
|
57
58
|
Back to all articles
|
|
58
59
|
</Link>
|
|
@@ -88,7 +89,7 @@ export function BlogTopicsIndexPage({
|
|
|
88
89
|
href={buildBlogPath(normalizedBasePath, `topics/${topic.slug}`)}
|
|
89
90
|
className="group rounded-2xl border border-[var(--blog-accent-border)] bg-[var(--blog-card-bg)] p-5 transition hover:border-[var(--blog-accent)] hover:no-underline"
|
|
90
91
|
>
|
|
91
|
-
<h2 className="group-hover:text-[var(--blog-accent)]
|
|
92
|
+
<h2 className="text-xl font-semibold transition-colors group-hover:text-[var(--blog-accent)]">
|
|
92
93
|
{topic.name}
|
|
93
94
|
</h2>
|
|
94
95
|
{topic.description && (
|
|
@@ -126,10 +127,19 @@ export function BlogTopicArchivePage({
|
|
|
126
127
|
className,
|
|
127
128
|
)}
|
|
128
129
|
>
|
|
130
|
+
{archive && (
|
|
131
|
+
<ArchiveStructuredData
|
|
132
|
+
articles={archive.articles}
|
|
133
|
+
title={archive.topic.name}
|
|
134
|
+
kind="topics"
|
|
135
|
+
slug={topicSlug}
|
|
136
|
+
basePath={normalizedBasePath}
|
|
137
|
+
/>
|
|
138
|
+
)}
|
|
129
139
|
<header className="mb-10 space-y-3 text-center">
|
|
130
140
|
<Link
|
|
131
141
|
href={buildBlogPath(normalizedBasePath, "topics")}
|
|
132
|
-
className="text-muted-foreground hover:text-[var(--blog-accent)]
|
|
142
|
+
className="text-muted-foreground text-sm font-medium transition-colors hover:text-[var(--blog-accent)]"
|
|
133
143
|
>
|
|
134
144
|
Back to all topics
|
|
135
145
|
</Link>
|
|
@@ -202,7 +212,7 @@ function TopicArticleCard({
|
|
|
202
212
|
<span className="inline-flex rounded-full border border-[var(--blog-accent-border)] px-2.5 py-1 text-xs font-medium text-[var(--blog-text-accent)]">
|
|
203
213
|
{topic.name}
|
|
204
214
|
</span>
|
|
205
|
-
<h2 className="group-hover:text-[var(--blog-accent)]
|
|
215
|
+
<h2 className="text-xl font-semibold text-balance transition-colors group-hover:text-[var(--blog-accent)]">
|
|
206
216
|
{article.title}
|
|
207
217
|
</h2>
|
|
208
218
|
{article.excerpt && (
|
|
@@ -44,6 +44,8 @@ interface PublishedAuthor {
|
|
|
44
44
|
readonly pronouns?: string;
|
|
45
45
|
/** Whether this is a guest author */
|
|
46
46
|
readonly isGuest: boolean;
|
|
47
|
+
/** Areas of subject expertise. */
|
|
48
|
+
readonly expertise?: readonly string[];
|
|
47
49
|
/** Tone adjectives that describe this author's writing style */
|
|
48
50
|
readonly toneAdjectives: readonly string[];
|
|
49
51
|
}
|
|
@@ -162,6 +164,31 @@ interface PublishedSeriesContext {
|
|
|
162
164
|
/** Ordered article list for compact navigation and archive pages */
|
|
163
165
|
readonly articles: readonly PublishedSeriesArticleLink[];
|
|
164
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
|
+
}
|
|
165
192
|
/**
|
|
166
193
|
* Represents a published article with all necessary metadata for rendering
|
|
167
194
|
* on external blog platforms.
|
|
@@ -195,6 +222,8 @@ interface PublishedArticle {
|
|
|
195
222
|
readonly topics?: readonly PublishedTopic[];
|
|
196
223
|
/** Optional ordered series context for article navigation */
|
|
197
224
|
readonly series?: PublishedSeriesContext;
|
|
225
|
+
/** Optional video shown in place of the featured image at the top */
|
|
226
|
+
readonly video?: PublishedArticleVideo;
|
|
198
227
|
/** Featured image URL (optional) */
|
|
199
228
|
readonly featuredImage?: string;
|
|
200
229
|
/** Alt text for featured image */
|
|
@@ -268,4 +297,4 @@ interface PublishResult {
|
|
|
268
297
|
readonly idempotencyKey: string;
|
|
269
298
|
}
|
|
270
299
|
|
|
271
|
-
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,
|
|
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 };
|