@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { PublishedTopic, PublishedArticle } from '../index.mjs';
|
|
2
2
|
|
|
3
3
|
interface RelatedArticleCandidate {
|
|
4
4
|
readonly id: string;
|
|
@@ -14,4 +14,5 @@ interface RelatedFeaturedArticlesOptions {
|
|
|
14
14
|
declare function getRelatedFeaturedArticles<TArticle extends RelatedArticleCandidate>(currentArticle: RelatedArticleCandidate, articles: readonly TArticle[], options?: RelatedFeaturedArticlesOptions): TArticle[];
|
|
15
15
|
type RelatedPublishedArticle = PublishedArticle;
|
|
16
16
|
|
|
17
|
-
export {
|
|
17
|
+
export { getRelatedFeaturedArticles };
|
|
18
|
+
export type { RelatedArticleCandidate, RelatedFeaturedArticlesOptions, RelatedPublishedArticle };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { PublishedTopic, PublishedArticle } from '../index.js';
|
|
2
2
|
|
|
3
3
|
interface RelatedArticleCandidate {
|
|
4
4
|
readonly id: string;
|
|
@@ -14,4 +14,5 @@ interface RelatedFeaturedArticlesOptions {
|
|
|
14
14
|
declare function getRelatedFeaturedArticles<TArticle extends RelatedArticleCandidate>(currentArticle: RelatedArticleCandidate, articles: readonly TArticle[], options?: RelatedFeaturedArticlesOptions): TArticle[];
|
|
15
15
|
type RelatedPublishedArticle = PublishedArticle;
|
|
16
16
|
|
|
17
|
-
export {
|
|
17
|
+
export { getRelatedFeaturedArticles };
|
|
18
|
+
export type { RelatedArticleCandidate, RelatedFeaturedArticlesOptions, RelatedPublishedArticle };
|
package/dist/lib/sitemap.ts
CHANGED
|
@@ -56,10 +56,10 @@ export interface SitemapOptions {
|
|
|
56
56
|
/** Whether to include series pages in sitemap */
|
|
57
57
|
includeSeriesPages?: boolean;
|
|
58
58
|
|
|
59
|
-
/**
|
|
59
|
+
/** @deprecated Pagination pages are never included. */
|
|
60
60
|
includePaginationPages?: boolean;
|
|
61
61
|
|
|
62
|
-
/**
|
|
62
|
+
/** @deprecated Pagination pages are never included. */
|
|
63
63
|
articlesPerPage?: number;
|
|
64
64
|
|
|
65
65
|
/** Maximum number of sitemap entries (defaults to 50,000 per Google guidelines) */
|
|
@@ -213,8 +213,6 @@ export async function generateBlogSitemapEntries(
|
|
|
213
213
|
includeAuthorPages = true,
|
|
214
214
|
includeTopicPages = true,
|
|
215
215
|
includeSeriesPages = true,
|
|
216
|
-
includePaginationPages = true,
|
|
217
|
-
articlesPerPage = 10,
|
|
218
216
|
maxEntries = 50000,
|
|
219
217
|
changeFrequency = {},
|
|
220
218
|
priority = {},
|
|
@@ -338,22 +336,6 @@ export async function generateBlogSitemapEntries(
|
|
|
338
336
|
});
|
|
339
337
|
}
|
|
340
338
|
|
|
341
|
-
// Add pagination pages if enabled
|
|
342
|
-
if (includePaginationPages && articles.length > articlesPerPage) {
|
|
343
|
-
const totalPages = Math.ceil(articles.length / articlesPerPage);
|
|
344
|
-
|
|
345
|
-
for (let page = 2; page <= totalPages; page++) {
|
|
346
|
-
if (entries.length >= maxEntries) break;
|
|
347
|
-
|
|
348
|
-
entries.push({
|
|
349
|
-
url: `${baseUrl}${pathPrefix}?page=${page}`,
|
|
350
|
-
lastModified: getLatestModificationDate(articles),
|
|
351
|
-
changeFrequency: changeFrequency.paginationPages ?? "daily",
|
|
352
|
-
priority: priority.paginationPages ?? 0.5,
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
339
|
return entries;
|
|
358
340
|
}
|
|
359
341
|
|
|
@@ -377,8 +359,6 @@ export function generateSitemap(
|
|
|
377
359
|
includeAuthorPages = true,
|
|
378
360
|
includeTopicPages = true,
|
|
379
361
|
includeSeriesPages = true,
|
|
380
|
-
includePaginationPages = true,
|
|
381
|
-
articlesPerPage = 10,
|
|
382
362
|
maxEntries = 50000,
|
|
383
363
|
changeFrequency = {},
|
|
384
364
|
priority = {},
|
|
@@ -410,10 +390,6 @@ export function generateSitemap(
|
|
|
410
390
|
potentialEntries +=
|
|
411
391
|
sitemapAuthors.length > 0 ? sitemapAuthors.length + 1 : 0; // Authors index + author pages
|
|
412
392
|
}
|
|
413
|
-
if (includePaginationPages && articles.length > articlesPerPage) {
|
|
414
|
-
const totalPages = Math.ceil(articles.length / articlesPerPage);
|
|
415
|
-
potentialEntries += totalPages - 1; // Pagination pages (excluding page 1 which is home)
|
|
416
|
-
}
|
|
417
393
|
|
|
418
394
|
const willTruncate = potentialEntries > maxEntries;
|
|
419
395
|
|
|
@@ -523,22 +499,6 @@ export function generateSitemap(
|
|
|
523
499
|
});
|
|
524
500
|
}
|
|
525
501
|
|
|
526
|
-
// Add pagination pages if enabled
|
|
527
|
-
if (includePaginationPages && articles.length > articlesPerPage) {
|
|
528
|
-
const totalPages = Math.ceil(articles.length / articlesPerPage);
|
|
529
|
-
|
|
530
|
-
for (let page = 2; page <= totalPages; page++) {
|
|
531
|
-
if (entries.length >= maxEntries) break;
|
|
532
|
-
|
|
533
|
-
entries.push({
|
|
534
|
-
loc: `${baseUrl}${pathPrefix}?page=${page}`,
|
|
535
|
-
lastmod: getLatestModificationDate(articles),
|
|
536
|
-
changefreq: changeFrequency.paginationPages ?? "daily",
|
|
537
|
-
priority: priority.paginationPages ?? 0.5,
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
502
|
return {
|
|
543
503
|
entries,
|
|
544
504
|
totalEntries: entries.length,
|
|
@@ -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
|
|
@@ -222,23 +247,50 @@ export function generateArticleStructuredData(
|
|
|
222
247
|
schema.inLanguage = language ?? article.site.locale;
|
|
223
248
|
}
|
|
224
249
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
250
|
+
const subjects = [
|
|
251
|
+
...(article.topics ?? []).map((topic) => topic.name),
|
|
252
|
+
...(article.series ? [article.series.title] : []),
|
|
253
|
+
];
|
|
254
|
+
if (subjects.length) {
|
|
255
|
+
schema.about = [...new Set(subjects)].map((name) => ({
|
|
228
256
|
"@type": "Thing" as const,
|
|
229
|
-
name
|
|
257
|
+
name,
|
|
230
258
|
}));
|
|
231
259
|
}
|
|
232
260
|
|
|
233
|
-
|
|
234
|
-
if (article.site.toneAdjectives.length > 0) {
|
|
235
|
-
schema.genre = [...article.site.toneAdjectives];
|
|
236
|
-
}
|
|
261
|
+
|
|
237
262
|
}
|
|
238
263
|
|
|
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
|
*/
|
|
@@ -288,9 +340,8 @@ export function generateAuthorStructuredData(
|
|
|
288
340
|
schema.additionalType = "GuestAuthor";
|
|
289
341
|
}
|
|
290
342
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
schema.knowsAbout = [...author.toneAdjectives];
|
|
343
|
+
if (author.expertise?.length) {
|
|
344
|
+
schema.knowsAbout = [...author.expertise];
|
|
294
345
|
}
|
|
295
346
|
|
|
296
347
|
return schema;
|
|
@@ -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-
|
|
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.
|
|
@@ -173,9 +173,9 @@ interface SitemapOptions {
|
|
|
173
173
|
includeTopicPages?: boolean;
|
|
174
174
|
/** Whether to include series pages in sitemap */
|
|
175
175
|
includeSeriesPages?: boolean;
|
|
176
|
-
/**
|
|
176
|
+
/** @deprecated Pagination pages are never included. */
|
|
177
177
|
includePaginationPages?: boolean;
|
|
178
|
-
/**
|
|
178
|
+
/** @deprecated Pagination pages are never included. */
|
|
179
179
|
articlesPerPage?: number;
|
|
180
180
|
/** Maximum number of sitemap entries (defaults to 50,000 per Google guidelines) */
|
|
181
181
|
maxEntries?: number;
|
|
@@ -425,4 +425,5 @@ interface BlogSitemapConfig {
|
|
|
425
425
|
*/
|
|
426
426
|
declare function createDynamicBlogSitemap(config: BlogSitemapConfig): () => Promise<NextjsSitemapEntry[]>;
|
|
427
427
|
|
|
428
|
-
export {
|
|
428
|
+
export { createBlogSitemapRoute as d, createDynamicBlogSitemap as e, generateCanonicalUrl as f, generateBlogSitemapEntries as g, generateCanonicalUrlFromSlug as h, generateSitemap as i, generateSitemapIndex as j, getBaseUrl as k, isValidCanonicalUrl as l, normalizeSlug as m, normalizeDomain as n, sitemapToXml as s, updateArticleCanonicalUrl as u, validateSitemap as v };
|
|
429
|
+
export type { BlogSitemapConfig as B, CanonicalUrlOptions as C, NextjsSitemapEntry as N, SitemapChangeFreq as S, SitemapData as a, SitemapEntry as b, SitemapOptions as c };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as PublishedArticle, b as PublishedSite, c as PublishedAuthor } from './index-
|
|
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.
|
|
@@ -173,9 +173,9 @@ interface SitemapOptions {
|
|
|
173
173
|
includeTopicPages?: boolean;
|
|
174
174
|
/** Whether to include series pages in sitemap */
|
|
175
175
|
includeSeriesPages?: boolean;
|
|
176
|
-
/**
|
|
176
|
+
/** @deprecated Pagination pages are never included. */
|
|
177
177
|
includePaginationPages?: boolean;
|
|
178
|
-
/**
|
|
178
|
+
/** @deprecated Pagination pages are never included. */
|
|
179
179
|
articlesPerPage?: number;
|
|
180
180
|
/** Maximum number of sitemap entries (defaults to 50,000 per Google guidelines) */
|
|
181
181
|
maxEntries?: number;
|
|
@@ -425,4 +425,5 @@ interface BlogSitemapConfig {
|
|
|
425
425
|
*/
|
|
426
426
|
declare function createDynamicBlogSitemap(config: BlogSitemapConfig): () => Promise<NextjsSitemapEntry[]>;
|
|
427
427
|
|
|
428
|
-
export {
|
|
428
|
+
export { createBlogSitemapRoute as d, createDynamicBlogSitemap as e, generateCanonicalUrl as f, generateBlogSitemapEntries as g, generateCanonicalUrlFromSlug as h, generateSitemap as i, generateSitemapIndex as j, getBaseUrl as k, isValidCanonicalUrl as l, normalizeSlug as m, normalizeDomain as n, sitemapToXml as s, updateArticleCanonicalUrl as u, validateSitemap as v };
|
|
429
|
+
export type { BlogSitemapConfig as B, CanonicalUrlOptions as C, NextjsSitemapEntry as N, SitemapChangeFreq as S, SitemapData as a, SitemapEntry as b, SitemapOptions as c };
|
package/dist/sitemap.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import './index-
|
|
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-
|
|
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-
|
|
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-
|
|
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';
|
package/dist/sitemap.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
'use strict';function
|
|
1
|
+
'use strict';function v(t){let e=new Map;for(let r of t)r.author?.slug&&e.set(r.author.slug,F(e.get(r.author.slug),r.author));return Array.from(e.values()).sort((r,i)=>r.name.localeCompare(i.name))}function F(t,e){return t?{...t,bio:t.bio??e.bio,avatarUrl:t.avatarUrl??e.avatarUrl,pronouns:t.pronouns??e.pronouns,socialLinks:t.socialLinks??e.socialLinks,toneAdjectives:t.toneAdjectives.length>0?t.toneAdjectives:e.toneAdjectives}:e}function w(t,e=true){let r=t.trim();return r=r.replace(/\/+$/,""),/^https?:\/\//.exec(r)||(r=`${e?"https":"http"}://${r}`),e&&r.startsWith("http://")&&(r=r.replace("http://","https://")),r}function M(t){let e=t.trim();if(!e)throw new Error("Slug cannot be empty");if(!/^[a-z0-9-]+$/.test(e))throw new Error("Slug can only contain lowercase letters, numbers, and hyphens");let i=e.replace(/-+/g,"-").replace(/^-+|-+$/g,"");if(!i)throw new Error("Slug cannot consist only of hyphens");return i}function T(t,e={}){let{forceHttps:r=true,removeTrailingSlash:i=true,domain:n=t.site.domain,pathPrefix:o="/blog"}=e;if(!n)throw new Error("Domain is required for canonical URL generation");if(!t.slug)throw new Error("Article slug is required for canonical URL generation");let u=w(n,r),l=M(t.slug),c=o.replace(/^(?!\/)/,"/").replace(/\/+$/,""),a=`${u}${c}/${l}`;return i&&(a=a.replace(/\/+$/,"")),a}function O(t){let e=new Map;for(let r of t)r.series&&(!e.has(r.series.slug)||r.series.articles.length>(e.get(r.series.slug)?.articles.length??0))&&e.set(r.series.slug,r.series);return Array.from(e.values()).sort((r,i)=>r.title.localeCompare(i.title))}function _(t){let e=new Map;for(let r of t)for(let i of r.topics??[])e.has(i.slug)||e.set(i.slug,i);return Array.from(e.values()).sort((r,i)=>r.name.localeCompare(i.name))}function L(t="/blog"){let e=t.trim();return !e||e==="/"?"":e.replace(/^(?!\/)/,"/").replace(/\/+$/,"")}function P(t,e){let r=L(t),i=e?.replace(/^\/+/,"").replace(/\/+$/,"");return i?`${r}/${i}`.replace(/\/+/g,"/"):r||"/"}function U(t,e){return P(t,`authors/${e}`)}function B(t,e){return P(t,`series/${e}`)}async function q(t,e,r=[],i={}){let{includeAuthorPages:n=true,includeTopicPages:o=true,includeSeriesPages:u=true,maxEntries:l=5e4,changeFrequency:c={},priority:a={}}=i,s=[],h=w(i.canonical?.domain??t.domain,i.canonical?.forceHttps??true),p=i.canonical?.pathPrefix??"/blog",$=r.length>0?r:v(e),b=_([...e]),y=O([...e]);return s.push({url:`${h}${p}`,lastModified:m(e),changeFrequency:c.homePage??"daily",priority:a.homePage??1}),e.forEach(g=>{if(s.length>=l)return;let f=T(g,i.canonical);s.push({url:f,lastModified:g.updatedAt,changeFrequency:c.articles??"monthly",priority:a.articles??.8});}),o&&b.length>0&&(s.length<l&&s.push({url:`${h}${P(p,"topics")}`,lastModified:m(e),changeFrequency:c.topicPages??"weekly",priority:a.topicPages??.6}),b.forEach(g=>{if(s.length>=l)return;let f=e.filter(S=>(S.topics??[]).some(d=>d.slug===g.slug));s.push({url:`${h}${P(p,`topics/${g.slug}`)}`,lastModified:m(f),changeFrequency:c.topicPages??"weekly",priority:a.topicPages??.6});})),u&&y.length>0&&(s.length<l&&s.push({url:`${h}${P(p,"series")}`,lastModified:m(e),changeFrequency:c.seriesPages??"weekly",priority:a.seriesPages??.6}),y.forEach(g=>{if(s.length>=l)return;let f=e.filter(S=>S.series?.slug===g.slug);s.push({url:`${h}${B(p,g.slug)}`,lastModified:m(f),changeFrequency:c.seriesPages??"weekly",priority:a.seriesPages??.6});})),n&&$.length>0&&(s.length<l&&s.push({url:`${h}${P(p,"authors")}`,lastModified:m(e),changeFrequency:c.authorPages??"weekly",priority:a.authorPages??.6}),$.forEach(g=>{if(s.length>=l)return;let f=`${h}${U(p,g.slug)}`,S=e.filter(A=>A.author?.id===g.id),d=m(S);s.push({url:f,lastModified:d,changeFrequency:c.authorPages??"weekly",priority:a.authorPages??.6});})),s}function K(t,e,r=[],i={}){let{includeAuthorPages:n=true,includeTopicPages:o=true,includeSeriesPages:u=true,maxEntries:l=5e4,changeFrequency:c={},priority:a={}}=i,s=[],h=w(i.canonical?.domain??t.domain,i.canonical?.forceHttps??true),p=i.canonical?.pathPrefix??"/blog",$=new Date().toISOString(),b=r.length>0?r:v(e),y=_([...e]),g=O([...e]),f=1;f+=e.length,o&&y.length>0&&(f+=y.length+1),u&&g.length>0&&(f+=g.length+1),n&&(f+=b.length>0?b.length+1:0);let S=f>l;return s.push({loc:`${h}${p}`,lastmod:m(e),changefreq:c.homePage??"daily",priority:a.homePage??1}),e.forEach(d=>{if(s.length>=l)return;let A=T(d,i.canonical);s.push({loc:A,lastmod:d.updatedAt,changefreq:c.articles??"monthly",priority:a.articles??.8});}),o&&y.length>0&&(s.length<l&&s.push({loc:`${h}${P(p,"topics")}`,lastmod:m(e),changefreq:c.topicPages??"weekly",priority:a.topicPages??.6}),y.forEach(d=>{if(s.length>=l)return;let A=e.filter(x=>(x.topics??[]).some(E=>E.slug===d.slug));s.push({loc:`${h}${P(p,`topics/${d.slug}`)}`,lastmod:m(A),changefreq:c.topicPages??"weekly",priority:a.topicPages??.6});})),u&&g.length>0&&(s.length<l&&s.push({loc:`${h}${P(p,"series")}`,lastmod:m(e),changefreq:c.seriesPages??"weekly",priority:a.seriesPages??.6}),g.forEach(d=>{if(s.length>=l)return;let A=e.filter(x=>x.series?.slug===d.slug);s.push({loc:`${h}${B(p,d.slug)}`,lastmod:m(A),changefreq:c.seriesPages??"weekly",priority:a.seriesPages??.6});})),n&&b.length>0&&(s.length<l&&s.push({loc:`${h}${P(p,"authors")}`,lastmod:m(e),changefreq:c.authorPages??"weekly",priority:a.authorPages??.6}),b.forEach(d=>{if(s.length>=l)return;let A=`${h}${U(p,d.slug)}`,x=e.filter(C=>C.author?.id===d.id),E=m(x);s.push({loc:A,lastmod:E,changefreq:c.authorPages??"weekly",priority:a.authorPages??.6});})),{entries:s,totalEntries:s.length,truncated:S,generatedAt:$,baseUrl:h}}function k(t){let e='<?xml version="1.0" encoding="UTF-8"?>',r='<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',i="</urlset>",n=t.entries.map(o=>{let u=[];return u.push(" <url>"),u.push(` <loc>${R(o.loc)}</loc>`),o.lastmod&&u.push(` <lastmod>${R(o.lastmod)}</lastmod>`),o.changefreq&&u.push(` <changefreq>${R(o.changefreq)}</changefreq>`),o.priority!==void 0&&u.push(` <priority>${o.priority.toFixed(1)}</priority>`),u.push(" </url>"),u.join(`
|
|
2
2
|
`)});return [e,r,...n,i].join(`
|
|
3
|
-
`)}function
|
|
4
|
-
`)});return [r,i,...
|
|
5
|
-
`)}function
|
|
3
|
+
`)}function ee(t,e){let r='<?xml version="1.0" encoding="UTF-8"?>',i='<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',n="</sitemapindex>",o=t.map(u=>{let l=[];return l.push(" <sitemap>"),l.push(` <loc>${R(u.url)}</loc>`),u.lastmod&&l.push(` <lastmod>${R(u.lastmod)}</lastmod>`),l.push(" </sitemap>"),l.join(`
|
|
4
|
+
`)});return [r,i,...o,n].join(`
|
|
5
|
+
`)}function te(t){let e=[],r=[];t.entries.length===0?e.push("Sitemap must contain at least one URL"):t.entries.length>5e4&&e.push("Sitemap contains more than 50,000 URLs (Google limit)"),t.truncated&&r.push("Sitemap was truncated due to size limits"),t.entries.forEach((n,o)=>{try{new URL(n.loc);}catch{e.push(`Invalid URL at entry ${o+1}: ${n.loc}`);}n.priority!==void 0&&(n.priority<0||n.priority>1)&&e.push(`Invalid priority at entry ${o+1}: must be between 0.0 and 1.0`),n.lastmod&&!D(n.lastmod)&&e.push(`Invalid lastmod date at entry ${o+1}: ${n.lastmod}`),n.loc.length>2048&&r.push(`URL at entry ${o+1} exceeds 2048 character limit`);});let i=new Map;return t.entries.forEach(n=>{let o=i.get(n.loc)??0;i.set(n.loc,o+1);}),i.forEach((n,o)=>{n>1&&e.push(`Duplicate URL found: ${o} (appears ${n} times)`);}),{valid:e.length===0,errors:e,warnings:r}}function m(t){return t.length===0?new Date().toISOString():t.reduce((r,i)=>{let n=new Date(i.updatedAt);return n>r?n:r},new Date(t[0].updatedAt)).toISOString()}function re(t,e,r=[],i={}){return async function(){let u=(await q(t,e,r,i)).map(a=>({loc:a.url,lastmod:typeof a.lastModified=="string"?a.lastModified:a.lastModified?.toISOString(),changefreq:a.changeFrequency,priority:a.priority})),l={entries:u,totalEntries:u.length,generatedAt:new Date().toISOString(),baseUrl:w(i.canonical?.domain??t.domain,i.canonical?.forceHttps??true)},c=k(l);return new Response(c,{headers:{"Content-Type":"application/xml","Cache-Control":"public, max-age=3600, s-maxage=3600"}})}}function ie(t){return async function(){let r=[...t.staticRoutes??[],...t.getStaticRoutes?await t.getStaticRoutes():[]],i=await t.getSite(),n=await t.getArticles(),o=t.getAuthors?await t.getAuthors():[];return [...r,...await q(i,n,o,t.options)]}}function R(t){let e={"&":"&","<":"<",">":">",'"':""","'":"'"};return t.replace(/[&<>"']/g,r=>e[r]??r)}function D(t){try{let e=new Date(t),r=e.toISOString(),i=t.endsWith("Z")&&!t.includes(".")?t.replace("Z",".000Z"):t;return r===i&&!isNaN(e.getTime())}catch{return false}}exports.createBlogSitemapRoute=re;exports.createDynamicBlogSitemap=ie;exports.generateBlogSitemapEntries=q;exports.generateSitemap=K;exports.generateSitemapIndex=ee;exports.sitemapToXml=k;exports.validateSitemap=te;//# sourceMappingURL=sitemap.js.map
|
|
6
6
|
//# sourceMappingURL=sitemap.js.map
|