@xexr/blog-platform 1.0.7 → 1.0.8
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 +7 -0
- package/dist/app/blog/[slug]/page.d.ts +68 -0
- package/dist/app/blog/[slug]/page.tsx +155 -113
- package/dist/components/ArticleReadingProgress.tsx +208 -0
- package/dist/components/ArticleTopActions.tsx +179 -0
- package/dist/components/AuthorByline.tsx +569 -0
- package/dist/components/ExpandableArticleImage.tsx +79 -0
- package/dist/components/Pagination.tsx +236 -0
- package/dist/components/TopicArchivePages.tsx +204 -0
- package/dist/components/ui/badge.tsx +36 -0
- package/dist/components/ui/button.tsx +59 -0
- package/dist/components/ui/card.tsx +92 -0
- package/dist/components/ui/pagination.tsx +127 -0
- package/dist/components/ui/separator.tsx +29 -0
- package/dist/index.d.mts +59 -69
- package/dist/index.d.ts +59 -69
- package/dist/index.js +83 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +83 -8
- package/dist/index.mjs.map +1 -1
- package/dist/lib/article-ui.ts +158 -0
- package/dist/lib/auth.ts +354 -0
- package/dist/lib/cors.ts +243 -0
- package/dist/lib/database.ts +277 -0
- package/dist/lib/init.ts +186 -0
- package/dist/lib/mdx.ts +73 -0
- package/dist/lib/metadata.ts +558 -0
- package/dist/lib/ratelimit.ts +224 -0
- package/dist/lib/security-headers.ts +322 -0
- package/dist/lib/seo.ts +295 -0
- package/dist/lib/sitemap.ts +719 -0
- package/dist/lib/storage/drizzle.ts +400 -0
- package/dist/lib/structured-data.ts +608 -0
- package/dist/lib/topics.ts +56 -0
- package/dist/lib/urls.ts +29 -0
- package/dist/lib/utils.ts +6 -0
- package/dist/lib/version.ts +154 -0
- package/dist/types/contracts.ts +256 -0
- package/dist/types/index.d.mts +18 -1
- package/dist/types/index.d.ts +18 -1
- package/dist/types/index.ts +16 -0
- package/package.json +14 -9
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured Data (JSON-LD) utilities for SEO optimization
|
|
3
|
+
*
|
|
4
|
+
* This module provides comprehensive schema.org structured data generation
|
|
5
|
+
* for blog articles, authors, and organizations. It follows Google's
|
|
6
|
+
* recommended guidelines for structured data markup.
|
|
7
|
+
*
|
|
8
|
+
* @see https://developers.google.com/search/docs/appearance/structured-data
|
|
9
|
+
* @see https://schema.org/
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
PublishedArticle,
|
|
14
|
+
PublishedAuthor,
|
|
15
|
+
PublishedSite,
|
|
16
|
+
} from "../types/contracts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Base schema.org context and type
|
|
20
|
+
*/
|
|
21
|
+
interface BaseStructuredData {
|
|
22
|
+
"@context": "https://schema.org";
|
|
23
|
+
"@type": string;
|
|
24
|
+
"@id"?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Person schema for authors
|
|
29
|
+
* @see https://schema.org/Person
|
|
30
|
+
*/
|
|
31
|
+
export interface PersonSchema extends BaseStructuredData {
|
|
32
|
+
"@type": "Person";
|
|
33
|
+
name: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
identifier?: string;
|
|
36
|
+
image?: string;
|
|
37
|
+
url?: string;
|
|
38
|
+
sameAs?: string[];
|
|
39
|
+
jobTitle?: string;
|
|
40
|
+
affiliation?: OrganizationSchema;
|
|
41
|
+
knowsAbout?: string[];
|
|
42
|
+
additionalType?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Organization schema for blog sites
|
|
47
|
+
* @see https://schema.org/Organization
|
|
48
|
+
*/
|
|
49
|
+
export interface OrganizationSchema extends BaseStructuredData {
|
|
50
|
+
"@type": "Organization";
|
|
51
|
+
name: string;
|
|
52
|
+
description?: string;
|
|
53
|
+
url?: string;
|
|
54
|
+
logo?: string;
|
|
55
|
+
sameAs?: string[];
|
|
56
|
+
foundingDate?: string;
|
|
57
|
+
location?: string;
|
|
58
|
+
contactPoint?: {
|
|
59
|
+
"@type": "ContactPoint";
|
|
60
|
+
contactType: string;
|
|
61
|
+
url?: string;
|
|
62
|
+
email?: string;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* BlogPosting schema for articles
|
|
68
|
+
* @see https://schema.org/BlogPosting
|
|
69
|
+
*/
|
|
70
|
+
export interface BlogPostingSchema extends BaseStructuredData {
|
|
71
|
+
"@type": "BlogPosting";
|
|
72
|
+
headline: string;
|
|
73
|
+
description?: string;
|
|
74
|
+
image?: string | string[];
|
|
75
|
+
datePublished: string;
|
|
76
|
+
dateModified?: string;
|
|
77
|
+
author?: PersonSchema | PersonSchema[];
|
|
78
|
+
publisher: OrganizationSchema;
|
|
79
|
+
mainEntityOfPage: {
|
|
80
|
+
"@type": "WebPage";
|
|
81
|
+
"@id": string;
|
|
82
|
+
};
|
|
83
|
+
keywords?: string | string[];
|
|
84
|
+
wordCount?: number;
|
|
85
|
+
timeRequired?: string; // ISO 8601 duration format (PT5M = 5 minutes)
|
|
86
|
+
inLanguage?: string;
|
|
87
|
+
isAccessibleForFree?: boolean;
|
|
88
|
+
genre?: string | string[];
|
|
89
|
+
about?: {
|
|
90
|
+
"@type": "Thing";
|
|
91
|
+
name: string;
|
|
92
|
+
}[];
|
|
93
|
+
mentions?: {
|
|
94
|
+
"@type": "Thing";
|
|
95
|
+
name: string;
|
|
96
|
+
}[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* WebSite schema for the blog home page
|
|
101
|
+
* @see https://schema.org/WebSite
|
|
102
|
+
*/
|
|
103
|
+
export interface WebSiteSchema extends BaseStructuredData {
|
|
104
|
+
"@type": "WebSite";
|
|
105
|
+
name: string;
|
|
106
|
+
description?: string;
|
|
107
|
+
url: string;
|
|
108
|
+
publisher: OrganizationSchema;
|
|
109
|
+
inLanguage?: string;
|
|
110
|
+
potentialAction?: {
|
|
111
|
+
"@type": "SearchAction";
|
|
112
|
+
target: {
|
|
113
|
+
"@type": "EntryPoint";
|
|
114
|
+
urlTemplate: string;
|
|
115
|
+
};
|
|
116
|
+
"query-input": string;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* BreadcrumbList schema for navigation
|
|
122
|
+
* @see https://schema.org/BreadcrumbList
|
|
123
|
+
*/
|
|
124
|
+
export interface BreadcrumbListSchema extends BaseStructuredData {
|
|
125
|
+
"@type": "BreadcrumbList";
|
|
126
|
+
itemListElement: {
|
|
127
|
+
"@type": "ListItem";
|
|
128
|
+
position: number;
|
|
129
|
+
name: string;
|
|
130
|
+
item: string;
|
|
131
|
+
}[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Options for customizing structured data generation
|
|
136
|
+
*/
|
|
137
|
+
export interface StructuredDataOptions {
|
|
138
|
+
/** Include optional properties like keywords, mentions, etc. */
|
|
139
|
+
includeOptionalProperties?: boolean;
|
|
140
|
+
/** Include search action in WebSite schema */
|
|
141
|
+
includeSearchAction?: boolean;
|
|
142
|
+
/** Custom domain override for URLs */
|
|
143
|
+
customDomain?: string;
|
|
144
|
+
/** Path prefix for blog URLs (e.g., '/blog', '/articles') */
|
|
145
|
+
pathPrefix?: string;
|
|
146
|
+
/** Language code (e.g., 'en-US') */
|
|
147
|
+
language?: string;
|
|
148
|
+
/** Whether content is free to access */
|
|
149
|
+
isAccessibleForFree?: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Generate comprehensive structured data for a blog article
|
|
154
|
+
*/
|
|
155
|
+
export function generateArticleStructuredData(
|
|
156
|
+
article: PublishedArticle,
|
|
157
|
+
options: StructuredDataOptions = {},
|
|
158
|
+
): BlogPostingSchema {
|
|
159
|
+
const {
|
|
160
|
+
customDomain,
|
|
161
|
+
pathPrefix = "/blog",
|
|
162
|
+
language,
|
|
163
|
+
isAccessibleForFree = true,
|
|
164
|
+
includeOptionalProperties = true,
|
|
165
|
+
} = options;
|
|
166
|
+
|
|
167
|
+
const domain = customDomain ?? article.site.domain;
|
|
168
|
+
const canonicalUrl = customDomain
|
|
169
|
+
? `https://${domain}${pathPrefix}/${article.slug}`
|
|
170
|
+
: (article.meta.canonicalUrl ??
|
|
171
|
+
`https://${domain}${pathPrefix}/${article.slug}`);
|
|
172
|
+
|
|
173
|
+
// Generate author schema
|
|
174
|
+
const authorSchema = article.author
|
|
175
|
+
? generateAuthorStructuredData(article.author, { customDomain, pathPrefix })
|
|
176
|
+
: undefined;
|
|
177
|
+
|
|
178
|
+
// Generate publisher schema
|
|
179
|
+
const publisherSchema = generateOrganizationStructuredData(article.site, {
|
|
180
|
+
customDomain,
|
|
181
|
+
pathPrefix,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Convert reading seconds to ISO 8601 duration
|
|
185
|
+
const readingMinutes = Math.max(1, Math.ceil(article.readingSeconds / 60));
|
|
186
|
+
const timeRequired = `PT${readingMinutes}M`;
|
|
187
|
+
|
|
188
|
+
const schema: BlogPostingSchema = {
|
|
189
|
+
"@context": "https://schema.org",
|
|
190
|
+
"@type": "BlogPosting",
|
|
191
|
+
"@id": `${canonicalUrl}#article`,
|
|
192
|
+
headline: article.title,
|
|
193
|
+
description: article.excerpt,
|
|
194
|
+
datePublished: article.publishedAt,
|
|
195
|
+
dateModified: article.updatedAt,
|
|
196
|
+
author: authorSchema,
|
|
197
|
+
publisher: publisherSchema,
|
|
198
|
+
mainEntityOfPage: {
|
|
199
|
+
"@type": "WebPage",
|
|
200
|
+
"@id": canonicalUrl,
|
|
201
|
+
},
|
|
202
|
+
wordCount: article.wordCount,
|
|
203
|
+
timeRequired,
|
|
204
|
+
isAccessibleForFree,
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// Add optional properties if enabled
|
|
208
|
+
if (includeOptionalProperties) {
|
|
209
|
+
// Featured image
|
|
210
|
+
if (article.featuredImage) {
|
|
211
|
+
schema.image = article.featuredImage;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Keywords
|
|
215
|
+
if (article.meta.keywords.length > 0) {
|
|
216
|
+
schema.keywords = [...article.meta.keywords];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Language
|
|
220
|
+
if (language ?? article.site.locale) {
|
|
221
|
+
schema.inLanguage = language ?? article.site.locale;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Extract topics from tone adjectives or keywords for 'about' property
|
|
225
|
+
if (article.site.toneAdjectives.length > 0) {
|
|
226
|
+
schema.about = article.site.toneAdjectives.map((topic) => ({
|
|
227
|
+
"@type": "Thing" as const,
|
|
228
|
+
name: topic,
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Genre based on site description or tone
|
|
233
|
+
if (article.site.toneAdjectives.length > 0) {
|
|
234
|
+
schema.genre = [...article.site.toneAdjectives];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return schema;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Generate structured data for an author
|
|
243
|
+
*/
|
|
244
|
+
export function generateAuthorStructuredData(
|
|
245
|
+
author: PublishedAuthor,
|
|
246
|
+
options: StructuredDataOptions = {},
|
|
247
|
+
): PersonSchema {
|
|
248
|
+
const { customDomain, pathPrefix = "/blog" } = options;
|
|
249
|
+
|
|
250
|
+
const schema: PersonSchema = {
|
|
251
|
+
"@context": "https://schema.org",
|
|
252
|
+
"@type": "Person",
|
|
253
|
+
"@id": `#author-${author.id}`,
|
|
254
|
+
name: author.name,
|
|
255
|
+
identifier: author.id,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// Add optional properties
|
|
259
|
+
if (author.bio) {
|
|
260
|
+
schema.description = author.bio;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (author.avatarUrl) {
|
|
264
|
+
schema.image = author.avatarUrl;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const sameAs = [
|
|
268
|
+
author.socialLinks?.website,
|
|
269
|
+
author.socialLinks?.x,
|
|
270
|
+
author.socialLinks?.linkedin,
|
|
271
|
+
author.socialLinks?.github,
|
|
272
|
+
].filter((url): url is string => Boolean(url));
|
|
273
|
+
|
|
274
|
+
if (sameAs.length > 0) {
|
|
275
|
+
schema.sameAs = sameAs;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (author.slug?.trim()) {
|
|
279
|
+
const authorUrl = customDomain
|
|
280
|
+
? `https://${customDomain}${pathPrefix}/authors/${author.slug}`
|
|
281
|
+
: `${pathPrefix}/authors/${author.slug}`;
|
|
282
|
+
schema.url = authorUrl;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Additional type for guest authors
|
|
286
|
+
if (author.isGuest) {
|
|
287
|
+
schema.additionalType = "GuestAuthor";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Add knowledge areas from tone adjectives
|
|
291
|
+
if (author.toneAdjectives && author.toneAdjectives.length > 0) {
|
|
292
|
+
schema.knowsAbout = [...author.toneAdjectives];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return schema;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Generate structured data for a blog site organization
|
|
300
|
+
*/
|
|
301
|
+
export function generateOrganizationStructuredData(
|
|
302
|
+
site: PublishedSite,
|
|
303
|
+
options: StructuredDataOptions = {},
|
|
304
|
+
): OrganizationSchema {
|
|
305
|
+
const { customDomain, pathPrefix = "/blog" } = options;
|
|
306
|
+
|
|
307
|
+
const domain = customDomain ?? site.domain;
|
|
308
|
+
const siteUrl = `https://${domain}${pathPrefix}`;
|
|
309
|
+
|
|
310
|
+
const schema: OrganizationSchema = {
|
|
311
|
+
"@context": "https://schema.org",
|
|
312
|
+
"@type": "Organization",
|
|
313
|
+
"@id": `${siteUrl}#organization`,
|
|
314
|
+
name: site.name,
|
|
315
|
+
url: siteUrl,
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Add optional properties
|
|
319
|
+
if (site.description?.trim()) {
|
|
320
|
+
schema.description = site.description;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (site.tagline?.trim()) {
|
|
324
|
+
schema.description = schema.description
|
|
325
|
+
? `${schema.description}. ${site.tagline}`
|
|
326
|
+
: site.tagline;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return schema;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Generate structured data for the blog website
|
|
334
|
+
*/
|
|
335
|
+
export function generateWebSiteStructuredData(
|
|
336
|
+
site: PublishedSite,
|
|
337
|
+
options: StructuredDataOptions = {},
|
|
338
|
+
): WebSiteSchema {
|
|
339
|
+
const {
|
|
340
|
+
customDomain,
|
|
341
|
+
pathPrefix = "/blog",
|
|
342
|
+
language,
|
|
343
|
+
includeSearchAction = true,
|
|
344
|
+
} = options;
|
|
345
|
+
|
|
346
|
+
const domain = customDomain ?? site.domain;
|
|
347
|
+
const siteUrl = `https://${domain}${pathPrefix}`;
|
|
348
|
+
|
|
349
|
+
const publisherSchema = generateOrganizationStructuredData(site, options);
|
|
350
|
+
|
|
351
|
+
const schema: WebSiteSchema = {
|
|
352
|
+
"@context": "https://schema.org",
|
|
353
|
+
"@type": "WebSite",
|
|
354
|
+
"@id": `${siteUrl}#website`,
|
|
355
|
+
name: site.name,
|
|
356
|
+
url: siteUrl,
|
|
357
|
+
publisher: publisherSchema,
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// Add optional properties
|
|
361
|
+
if (site.description?.trim()) {
|
|
362
|
+
schema.description = site.description;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (language ?? site.locale) {
|
|
366
|
+
schema.inLanguage = language ?? site.locale;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Add search action for better search engine integration
|
|
370
|
+
if (includeSearchAction) {
|
|
371
|
+
schema.potentialAction = {
|
|
372
|
+
"@type": "SearchAction",
|
|
373
|
+
target: {
|
|
374
|
+
"@type": "EntryPoint",
|
|
375
|
+
urlTemplate: `${siteUrl}?search={search_term_string}`,
|
|
376
|
+
},
|
|
377
|
+
"query-input": "required name=search_term_string",
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return schema;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Generate breadcrumb structured data for navigation
|
|
386
|
+
*/
|
|
387
|
+
export function generateBreadcrumbStructuredData(
|
|
388
|
+
breadcrumbs: Array<{ name: string; url: string }>,
|
|
389
|
+
options: StructuredDataOptions = {},
|
|
390
|
+
): BreadcrumbListSchema {
|
|
391
|
+
const { customDomain } = options;
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
"@context": "https://schema.org",
|
|
395
|
+
"@type": "BreadcrumbList",
|
|
396
|
+
itemListElement: breadcrumbs.map((crumb, index) => ({
|
|
397
|
+
"@type": "ListItem" as const,
|
|
398
|
+
position: index + 1,
|
|
399
|
+
name: crumb.name,
|
|
400
|
+
item:
|
|
401
|
+
customDomain && !crumb.url.startsWith("http")
|
|
402
|
+
? `https://${customDomain}${crumb.url}`
|
|
403
|
+
: crumb.url,
|
|
404
|
+
})),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Generate combined structured data for a complete article page
|
|
410
|
+
*/
|
|
411
|
+
export function generateCompleteArticleStructuredData(
|
|
412
|
+
article: PublishedArticle,
|
|
413
|
+
options: StructuredDataOptions = {},
|
|
414
|
+
): Array<BlogPostingSchema | WebSiteSchema | BreadcrumbListSchema> {
|
|
415
|
+
const schemas: Array<
|
|
416
|
+
BlogPostingSchema | WebSiteSchema | BreadcrumbListSchema
|
|
417
|
+
> = [];
|
|
418
|
+
|
|
419
|
+
// Article schema
|
|
420
|
+
schemas.push(generateArticleStructuredData(article, options));
|
|
421
|
+
|
|
422
|
+
// Website schema
|
|
423
|
+
schemas.push(generateWebSiteStructuredData(article.site, options));
|
|
424
|
+
|
|
425
|
+
// Breadcrumb schema for article pages
|
|
426
|
+
const pathPrefix = options.pathPrefix ?? "/blog";
|
|
427
|
+
const breadcrumbs = [
|
|
428
|
+
{ name: "Home", url: "/" },
|
|
429
|
+
{ name: article.site.name, url: pathPrefix },
|
|
430
|
+
{ name: article.title, url: `${pathPrefix}/${article.slug}` },
|
|
431
|
+
];
|
|
432
|
+
schemas.push(generateBreadcrumbStructuredData(breadcrumbs, options));
|
|
433
|
+
|
|
434
|
+
return schemas;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Generate structured data for the blog home page
|
|
439
|
+
*/
|
|
440
|
+
export function generateBlogHomeStructuredData(
|
|
441
|
+
site: PublishedSite,
|
|
442
|
+
featuredArticles: PublishedArticle[] = [],
|
|
443
|
+
options: StructuredDataOptions = {},
|
|
444
|
+
): Array<WebSiteSchema | BlogPostingSchema> {
|
|
445
|
+
const schemas: Array<WebSiteSchema | BlogPostingSchema> = [];
|
|
446
|
+
|
|
447
|
+
// Website schema for the home page
|
|
448
|
+
schemas.push(generateWebSiteStructuredData(site, options));
|
|
449
|
+
|
|
450
|
+
// Featured articles schemas (limit to first 3-5 for performance)
|
|
451
|
+
if (featuredArticles.length > 0) {
|
|
452
|
+
const articleSchemas = featuredArticles
|
|
453
|
+
.slice(0, 5)
|
|
454
|
+
.map((article) => generateArticleStructuredData(article, options));
|
|
455
|
+
schemas.push(...articleSchemas);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return schemas;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Validate structured data schema
|
|
463
|
+
*/
|
|
464
|
+
export function validateStructuredData(schema: BaseStructuredData): {
|
|
465
|
+
isValid: boolean;
|
|
466
|
+
errors: string[];
|
|
467
|
+
} {
|
|
468
|
+
const errors: string[] = [];
|
|
469
|
+
|
|
470
|
+
// Check required fields
|
|
471
|
+
if (!schema["@context"]) {
|
|
472
|
+
errors.push("Missing required @context property");
|
|
473
|
+
} else if (schema["@context"] !== "https://schema.org") {
|
|
474
|
+
errors.push("@context must be 'https://schema.org'");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (!schema["@type"]) {
|
|
478
|
+
errors.push("Missing required @type property");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Type-specific validations
|
|
482
|
+
if (schema["@type"] === "BlogPosting") {
|
|
483
|
+
const blogSchema = schema as BlogPostingSchema;
|
|
484
|
+
if (!blogSchema.headline) {
|
|
485
|
+
errors.push("BlogPosting: Missing required headline");
|
|
486
|
+
}
|
|
487
|
+
if (!blogSchema.datePublished) {
|
|
488
|
+
errors.push("BlogPosting: Missing required datePublished");
|
|
489
|
+
}
|
|
490
|
+
if (!blogSchema.author) {
|
|
491
|
+
errors.push("BlogPosting: Missing required author");
|
|
492
|
+
}
|
|
493
|
+
if (!blogSchema.publisher) {
|
|
494
|
+
errors.push("BlogPosting: Missing required publisher");
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (schema["@type"] === "Person") {
|
|
499
|
+
const personSchema = schema as PersonSchema;
|
|
500
|
+
if (!personSchema.name) {
|
|
501
|
+
errors.push("Person: Missing required name");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (schema["@type"] === "Organization") {
|
|
506
|
+
const orgSchema = schema as OrganizationSchema;
|
|
507
|
+
if (!orgSchema.name) {
|
|
508
|
+
errors.push("Organization: Missing required name");
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
isValid: errors.length === 0,
|
|
514
|
+
errors,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Convert structured data to JSON-LD string for HTML embedding
|
|
520
|
+
*/
|
|
521
|
+
export function structuredDataToJsonLd(
|
|
522
|
+
schema: BaseStructuredData | BaseStructuredData[],
|
|
523
|
+
): string {
|
|
524
|
+
return JSON.stringify(schema, null, 0);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Create a React script element for JSON-LD structured data
|
|
529
|
+
*/
|
|
530
|
+
export function createStructuredDataScript(
|
|
531
|
+
schema: BaseStructuredData | BaseStructuredData[],
|
|
532
|
+
) {
|
|
533
|
+
return {
|
|
534
|
+
type: "application/ld+json",
|
|
535
|
+
dangerouslySetInnerHTML: {
|
|
536
|
+
__html: structuredDataToJsonLd(schema),
|
|
537
|
+
},
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Extract keywords from article content for structured data
|
|
543
|
+
*/
|
|
544
|
+
export function extractKeywordsFromArticle(
|
|
545
|
+
article: PublishedArticle,
|
|
546
|
+
): string[] {
|
|
547
|
+
const keywords = new Set<string>();
|
|
548
|
+
|
|
549
|
+
// Add meta keywords
|
|
550
|
+
article.meta.keywords.forEach((keyword) => keywords.add(keyword));
|
|
551
|
+
|
|
552
|
+
// Add site tone adjectives as topical keywords
|
|
553
|
+
article.site.toneAdjectives.forEach((tone) => keywords.add(tone));
|
|
554
|
+
|
|
555
|
+
// Add author tone adjectives
|
|
556
|
+
if (article.author) {
|
|
557
|
+
article.author.toneAdjectives.forEach((tone) => keywords.add(tone));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return Array.from(keywords);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Error types for structured data operations
|
|
565
|
+
*/
|
|
566
|
+
export class StructuredDataError extends Error {
|
|
567
|
+
constructor(
|
|
568
|
+
message: string,
|
|
569
|
+
public readonly code: string,
|
|
570
|
+
public readonly details?: unknown,
|
|
571
|
+
) {
|
|
572
|
+
super(message);
|
|
573
|
+
this.name = "StructuredDataError";
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Generate structured data with error handling
|
|
579
|
+
*/
|
|
580
|
+
export function safeGenerateArticleStructuredData(
|
|
581
|
+
article: PublishedArticle,
|
|
582
|
+
options: StructuredDataOptions = {},
|
|
583
|
+
): { schema?: BlogPostingSchema; error?: StructuredDataError } {
|
|
584
|
+
try {
|
|
585
|
+
const schema = generateArticleStructuredData(article, options);
|
|
586
|
+
const validation = validateStructuredData(schema);
|
|
587
|
+
|
|
588
|
+
if (!validation.isValid) {
|
|
589
|
+
return {
|
|
590
|
+
error: new StructuredDataError(
|
|
591
|
+
"Generated schema validation failed",
|
|
592
|
+
"VALIDATION_FAILED",
|
|
593
|
+
validation.errors,
|
|
594
|
+
),
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return { schema };
|
|
599
|
+
} catch (error) {
|
|
600
|
+
return {
|
|
601
|
+
error: new StructuredDataError(
|
|
602
|
+
"Failed to generate structured data",
|
|
603
|
+
"GENERATION_FAILED",
|
|
604
|
+
error,
|
|
605
|
+
),
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { PublishedArticle, PublishedTopic } from "../types/contracts";
|
|
2
|
+
|
|
3
|
+
export interface TopicArchive {
|
|
4
|
+
topic: PublishedTopic;
|
|
5
|
+
articles: PublishedArticle[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getArticlePrimaryTopic(
|
|
9
|
+
article: PublishedArticle,
|
|
10
|
+
): PublishedTopic | null {
|
|
11
|
+
const topics = article.topics ?? [];
|
|
12
|
+
return topics.find((topic) => topic.isPrimary) ?? topics[0] ?? null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getAllTopics(articles: PublishedArticle[]): PublishedTopic[] {
|
|
16
|
+
const topicsBySlug = new Map<string, PublishedTopic>();
|
|
17
|
+
|
|
18
|
+
for (const article of articles) {
|
|
19
|
+
for (const topic of article.topics ?? []) {
|
|
20
|
+
if (!topicsBySlug.has(topic.slug)) {
|
|
21
|
+
topicsBySlug.set(topic.slug, topic);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return Array.from(topicsBySlug.values()).sort((a, b) =>
|
|
27
|
+
a.name.localeCompare(b.name),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getTopicArchive(
|
|
32
|
+
articles: PublishedArticle[],
|
|
33
|
+
topicSlug: string,
|
|
34
|
+
): TopicArchive | null {
|
|
35
|
+
const matchingArticles = articles.filter((article) =>
|
|
36
|
+
(article.topics ?? []).some((topic) => topic.slug === topicSlug),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (matchingArticles.length === 0) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const topic =
|
|
44
|
+
matchingArticles
|
|
45
|
+
.flatMap((article) => article.topics ?? [])
|
|
46
|
+
.find((candidate) => candidate.slug === topicSlug) ?? null;
|
|
47
|
+
|
|
48
|
+
if (!topic) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
topic,
|
|
54
|
+
articles: matchingArticles,
|
|
55
|
+
};
|
|
56
|
+
}
|
package/dist/lib/urls.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL helpers shared by package UI, metadata, and sitemap integrations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export function normalizeBlogBasePath(basePath = "/blog"): string {
|
|
6
|
+
const trimmed = basePath.trim();
|
|
7
|
+
if (!trimmed || trimmed === "/") return "";
|
|
8
|
+
|
|
9
|
+
return trimmed.replace(/^(?!\/)/, "/").replace(/\/+$/, "");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function buildBlogPath(
|
|
13
|
+
basePath: string | undefined,
|
|
14
|
+
slug?: string,
|
|
15
|
+
): string {
|
|
16
|
+
const normalizedBasePath = normalizeBlogBasePath(basePath);
|
|
17
|
+
const cleanSlug = slug?.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
18
|
+
|
|
19
|
+
if (!cleanSlug) {
|
|
20
|
+
return normalizedBasePath || "/";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return `${normalizedBasePath}/${cleanSlug}`.replace(/\/+/g, "/");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getPathPrefixFromBlogUrl(blogUrl: string): string {
|
|
27
|
+
const parsed = new URL(blogUrl);
|
|
28
|
+
return normalizeBlogBasePath(parsed.pathname);
|
|
29
|
+
}
|