@levino/shipyard-blog 0.7.4 → 0.7.5

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/src/feeds.ts ADDED
@@ -0,0 +1,286 @@
1
+ interface FeedPost {
2
+ id: string
3
+ data: {
4
+ date: Date
5
+ title: string
6
+ description: string
7
+ draft?: boolean
8
+ unlisted?: boolean
9
+ tags?: string[]
10
+ }
11
+ }
12
+
13
+ interface I18nConfig {
14
+ locales: (string | { codes: string[] })[]
15
+ }
16
+
17
+ const escapeXml = (text: string): string =>
18
+ text
19
+ .replace(/&/g, '&')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;')
22
+ .replace(/"/g, '&quot;')
23
+ .replace(/'/g, '&apos;')
24
+
25
+ const buildUrl = (
26
+ baseUrl: string,
27
+ ...segments: (string | undefined | null)[]
28
+ ): string => {
29
+ const url = new URL(baseUrl)
30
+ const parts = [
31
+ url.pathname.replace(/\/$/, ''),
32
+ ...segments
33
+ .filter(Boolean)
34
+ .map((s) => (s as string).replace(/^\/|\/$/g, '')),
35
+ ]
36
+ url.pathname = `/${parts.filter(Boolean).join('/')}`
37
+ return url.toString()
38
+ }
39
+
40
+ const filterPosts = (
41
+ allPosts: FeedPost[],
42
+ includeDraftsInDev: boolean,
43
+ currentLocale: string | undefined,
44
+ i18n: I18nConfig | null | undefined | false,
45
+ limit: number,
46
+ isDev: boolean,
47
+ ): FeedPost[] =>
48
+ allPosts
49
+ .filter((post) => {
50
+ if (post.data.unlisted) return false
51
+ if (post.data.draft && !(isDev && includeDraftsInDev)) return false
52
+ return true
53
+ })
54
+ .filter((post) => {
55
+ if (i18n) {
56
+ const [pl] = post.id.split('/')
57
+ return pl === currentLocale
58
+ }
59
+ return true
60
+ })
61
+ .toSorted((a, b) => b.data.date.getTime() - a.data.date.getTime())
62
+ .slice(0, limit)
63
+
64
+ const getPostUrl = (
65
+ post: FeedPost,
66
+ routeBasePath: string,
67
+ baseUrl: string,
68
+ currentLocale: string | undefined,
69
+ i18n: I18nConfig | null | undefined | false,
70
+ ): string => {
71
+ if (i18n && currentLocale) {
72
+ const slug = post.id.replace(`${currentLocale}/`, '')
73
+ return buildUrl(baseUrl, currentLocale, routeBasePath, slug)
74
+ }
75
+ return buildUrl(baseUrl, routeBasePath, post.id)
76
+ }
77
+
78
+ export interface FeedParams {
79
+ allPosts: FeedPost[]
80
+ blogConfig: {
81
+ routeBasePath: string
82
+ blogTitle: string
83
+ blogDescription?: string
84
+ includeDraftsInDev: boolean
85
+ feedOptions: {
86
+ limit: number
87
+ title?: string
88
+ description?: string
89
+ }
90
+ }
91
+ site: URL | undefined
92
+ currentLocale: string | undefined
93
+ i18n: I18nConfig | null | undefined | false
94
+ isDev: boolean
95
+ }
96
+
97
+ export const createRssResponse = ({
98
+ allPosts,
99
+ blogConfig,
100
+ site,
101
+ currentLocale,
102
+ i18n,
103
+ isDev,
104
+ }: FeedParams): Response => {
105
+ const baseUrl = site?.toString() ?? 'https://example.com'
106
+ const { feedOptions, blogTitle, blogDescription, routeBasePath } = blogConfig
107
+ const posts = filterPosts(
108
+ allPosts,
109
+ blogConfig.includeDraftsInDev,
110
+ currentLocale,
111
+ i18n,
112
+ feedOptions.limit,
113
+ isDev,
114
+ )
115
+ const title = feedOptions.title ?? blogTitle
116
+ const description =
117
+ feedOptions.description ?? blogDescription ?? `${title} Feed`
118
+ const feedUrl = i18n
119
+ ? buildUrl(baseUrl, currentLocale ?? '', routeBasePath, 'rss.xml')
120
+ : buildUrl(baseUrl, routeBasePath, 'rss.xml')
121
+
122
+ const items = posts
123
+ .map((post) => {
124
+ const postUrl = getPostUrl(
125
+ post,
126
+ routeBasePath,
127
+ baseUrl,
128
+ currentLocale,
129
+ i18n,
130
+ )
131
+ return [
132
+ ' <item>',
133
+ ` <title>${escapeXml(post.data.title)}</title>`,
134
+ ` <link>${escapeXml(postUrl)}</link>`,
135
+ ` <description>${escapeXml(post.data.description)}</description>`,
136
+ ` <pubDate>${post.data.date.toUTCString()}</pubDate>`,
137
+ ` <guid>${escapeXml(postUrl)}</guid>`,
138
+ ' </item>',
139
+ ].join('\n')
140
+ })
141
+ .join('\n')
142
+
143
+ const xml = [
144
+ '<?xml version="1.0" encoding="UTF-8"?>',
145
+ '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
146
+ ' <channel>',
147
+ ` <title>${escapeXml(title)}</title>`,
148
+ ` <link>${escapeXml(baseUrl)}</link>`,
149
+ ` <description>${escapeXml(description)}</description>`,
150
+ ` <language>${currentLocale ?? 'en'}</language>`,
151
+ ` <atom:link href="${escapeXml(feedUrl)}" rel="self" type="application/rss+xml"/>`,
152
+ ` <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>`,
153
+ items,
154
+ ' </channel>',
155
+ '</rss>',
156
+ ].join('\n')
157
+
158
+ return new Response(xml, {
159
+ headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
160
+ })
161
+ }
162
+
163
+ export const createAtomResponse = ({
164
+ allPosts,
165
+ blogConfig,
166
+ site,
167
+ currentLocale,
168
+ i18n,
169
+ isDev,
170
+ }: FeedParams): Response => {
171
+ const baseUrl = site?.toString() ?? 'https://example.com'
172
+ const { feedOptions, blogTitle, blogDescription, routeBasePath } = blogConfig
173
+ const posts = filterPosts(
174
+ allPosts,
175
+ blogConfig.includeDraftsInDev,
176
+ currentLocale,
177
+ i18n,
178
+ feedOptions.limit,
179
+ isDev,
180
+ )
181
+ const title = feedOptions.title ?? blogTitle
182
+ const feedUrl = i18n
183
+ ? buildUrl(baseUrl, currentLocale ?? '', routeBasePath, 'atom.xml')
184
+ : buildUrl(baseUrl, routeBasePath, 'atom.xml')
185
+
186
+ const lastUpdated =
187
+ posts.length > 0
188
+ ? posts[0].data.date.toISOString()
189
+ : new Date().toISOString()
190
+ const subtitle = feedOptions.description ?? blogDescription
191
+
192
+ const entries = posts
193
+ .map((post) => {
194
+ const postUrl = getPostUrl(
195
+ post,
196
+ routeBasePath,
197
+ baseUrl,
198
+ currentLocale,
199
+ i18n,
200
+ )
201
+ return [
202
+ ' <entry>',
203
+ ` <title>${escapeXml(post.data.title)}</title>`,
204
+ ` <link href="${escapeXml(postUrl)}"/>`,
205
+ ` <id>${escapeXml(postUrl)}</id>`,
206
+ ` <updated>${post.data.date.toISOString()}</updated>`,
207
+ ` <summary>${escapeXml(post.data.description)}</summary>`,
208
+ ' </entry>',
209
+ ].join('\n')
210
+ })
211
+ .join('\n')
212
+
213
+ const xml = [
214
+ '<?xml version="1.0" encoding="UTF-8"?>',
215
+ `<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="${currentLocale ?? 'en'}">`,
216
+ ` <title>${escapeXml(title)}</title>`,
217
+ ` <link href="${escapeXml(baseUrl)}"/>`,
218
+ ` <link href="${escapeXml(feedUrl)}" rel="self" type="application/atom+xml"/>`,
219
+ ` <id>${escapeXml(baseUrl)}</id>`,
220
+ ` <updated>${lastUpdated}</updated>`,
221
+ ...(subtitle ? [` <subtitle>${escapeXml(subtitle)}</subtitle>`] : []),
222
+ entries,
223
+ '</feed>',
224
+ ].join('\n')
225
+
226
+ return new Response(xml, {
227
+ headers: { 'Content-Type': 'application/atom+xml; charset=utf-8' },
228
+ })
229
+ }
230
+
231
+ export const createJsonFeedResponse = ({
232
+ allPosts,
233
+ blogConfig,
234
+ site,
235
+ currentLocale,
236
+ i18n,
237
+ isDev,
238
+ }: FeedParams): Response => {
239
+ const baseUrl = site?.toString() ?? 'https://example.com'
240
+ const { feedOptions, blogTitle, blogDescription, routeBasePath } = blogConfig
241
+ const posts = filterPosts(
242
+ allPosts,
243
+ blogConfig.includeDraftsInDev,
244
+ currentLocale,
245
+ i18n,
246
+ feedOptions.limit,
247
+ isDev,
248
+ )
249
+ const title = feedOptions.title ?? blogTitle
250
+ const description = feedOptions.description ?? blogDescription
251
+ const feedUrl = i18n
252
+ ? buildUrl(baseUrl, currentLocale ?? '', routeBasePath, 'feed.json')
253
+ : buildUrl(baseUrl, routeBasePath, 'feed.json')
254
+
255
+ const items = posts.map((post) => {
256
+ const postUrl = getPostUrl(
257
+ post,
258
+ routeBasePath,
259
+ baseUrl,
260
+ currentLocale,
261
+ i18n,
262
+ )
263
+ return {
264
+ id: postUrl,
265
+ url: postUrl,
266
+ title: post.data.title,
267
+ summary: post.data.description,
268
+ date_published: post.data.date.toISOString(),
269
+ ...(post.data.tags?.length ? { tags: post.data.tags } : {}),
270
+ }
271
+ })
272
+
273
+ const feed = {
274
+ version: 'https://jsonfeed.org/version/1.1',
275
+ title,
276
+ home_page_url: baseUrl,
277
+ feed_url: feedUrl,
278
+ ...(description ? { description } : {}),
279
+ language: currentLocale ?? 'en',
280
+ items,
281
+ }
282
+
283
+ return new Response(JSON.stringify(feed, null, 2), {
284
+ headers: { 'Content-Type': 'application/feed+json; charset=utf-8' },
285
+ })
286
+ }
package/src/index.ts CHANGED
@@ -166,13 +166,15 @@ export const blogConfigSchema = z.object({
166
166
  /**
167
167
  * The base path where blog routes will be mounted.
168
168
  * @default 'blog'
169
- * @example 'news' will mount blog at /news/[...slug]
169
+ * @example 'blog' will mount at /blog/[...slug]
170
+ * @example 'news' will mount at /news/[...slug]
170
171
  */
171
172
  routeBasePath: z.string().default('blog'),
172
173
  /**
173
174
  * The name of the content collection to use.
174
175
  * Must match a collection defined in your content.config.ts.
175
- * @default Same as routeBasePath (e.g., 'blog' or 'news')
176
+ * Defaults to the routeBasePath if not specified.
177
+ * @example 'blog' for a collection named 'blog'
176
178
  */
177
179
  collectionName: z.string().optional(),
178
180
  /**
@@ -300,26 +302,39 @@ const VIRTUAL_MODULE_ID = 'virtual:shipyard-blog/config'
300
302
  const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`
301
303
  const VIRTUAL_TAGS_MODULE_ID = 'virtual:shipyard-blog/tags'
302
304
  const RESOLVED_VIRTUAL_TAGS_MODULE_ID = `\0${VIRTUAL_TAGS_MODULE_ID}`
305
+ const VIRTUAL_REGISTRY_ID = 'virtual:shipyard-blog/registry'
306
+ const RESOLVED_VIRTUAL_REGISTRY_ID = `\0${VIRTUAL_REGISTRY_ID}`
307
+
308
+ const blogRegistry: Record<
309
+ string,
310
+ {
311
+ blogConfig: BlogConfig
312
+ tagsMap: Record<string, unknown>
313
+ collectionName: string
314
+ }
315
+ > = {}
303
316
 
304
317
  export default (options: Partial<BlogConfig> = {}): AstroIntegration => {
305
- // Parse and validate config
306
318
  const blogConfig = blogConfigSchema.parse(options)
307
319
 
308
- // Load tags map if path is provided
309
- let tagsMap: Record<string, unknown> = {}
310
- if (blogConfig.tagsMapPath) {
311
- // Defer loading to avoid issues at module parse time
312
- // Tags will be loaded by the virtual module
320
+ let normalizedBasePath = blogConfig.routeBasePath
321
+ while (normalizedBasePath.startsWith('/')) {
322
+ normalizedBasePath = normalizedBasePath.slice(1)
313
323
  }
324
+ while (normalizedBasePath.endsWith('/')) {
325
+ normalizedBasePath = normalizedBasePath.slice(0, -1)
326
+ }
327
+
328
+ const resolvedCollectionName = blogConfig.collectionName ?? normalizedBasePath
329
+
330
+ let tagsMap: Record<string, unknown> = {}
314
331
 
315
332
  return {
316
- name: 'shipyard-blog',
333
+ name: `shipyard-blog-${normalizedBasePath}`,
317
334
  hooks: {
318
335
  'astro:config:setup': ({ injectRoute, config, updateConfig }) => {
319
- // Load tags map now (at config setup time)
320
336
  if (blogConfig.tagsMapPath) {
321
337
  try {
322
- // Resolve relative to CWD (project root)
323
338
  const resolvedPath = blogConfig.tagsMapPath.startsWith('/')
324
339
  ? blogConfig.tagsMapPath
325
340
  : `${process.cwd()}/${blogConfig.tagsMapPath}`
@@ -329,7 +344,6 @@ export default (options: Partial<BlogConfig> = {}): AstroIntegration => {
329
344
  const rawData = parseYaml(fileContent)
330
345
 
331
346
  if (rawData && typeof rawData === 'object') {
332
- // Validate each tag against the schema
333
347
  for (const [key, value] of Object.entries(rawData)) {
334
348
  const result = tagSchema.safeParse(value)
335
349
  if (result.success) {
@@ -339,149 +353,145 @@ export default (options: Partial<BlogConfig> = {}): AstroIntegration => {
339
353
  }
340
354
  }
341
355
  } catch {
342
- // Tags file doesn't exist or is invalid, use empty map
343
356
  tagsMap = {}
344
357
  }
345
358
  }
346
359
 
347
- // Add a vite plugin to provide the config via a virtual module
360
+ blogRegistry[normalizedBasePath] = {
361
+ blogConfig,
362
+ tagsMap,
363
+ collectionName: resolvedCollectionName,
364
+ }
365
+
348
366
  updateConfig({
349
367
  vite: {
350
368
  plugins: [
351
369
  {
352
- name: 'shipyard-blog-config',
370
+ name: `shipyard-blog-config-${normalizedBasePath}`,
353
371
  resolveId(id) {
354
- if (id === VIRTUAL_MODULE_ID) {
372
+ if (id === VIRTUAL_REGISTRY_ID)
373
+ return RESOLVED_VIRTUAL_REGISTRY_ID
374
+ if (id === VIRTUAL_MODULE_ID)
355
375
  return RESOLVED_VIRTUAL_MODULE_ID
356
- }
357
- if (id === VIRTUAL_TAGS_MODULE_ID) {
376
+ if (id === VIRTUAL_TAGS_MODULE_ID)
358
377
  return RESOLVED_VIRTUAL_TAGS_MODULE_ID
359
- }
360
378
  },
361
379
  load(id) {
362
- if (id === RESOLVED_VIRTUAL_MODULE_ID) {
380
+ if (id === RESOLVED_VIRTUAL_REGISTRY_ID)
381
+ return `export default ${JSON.stringify(blogRegistry)}`
382
+ if (id === RESOLVED_VIRTUAL_MODULE_ID)
363
383
  return `export default ${JSON.stringify(blogConfig)}`
364
- }
365
- if (id === RESOLVED_VIRTUAL_TAGS_MODULE_ID) {
384
+ if (id === RESOLVED_VIRTUAL_TAGS_MODULE_ID)
366
385
  return `export default ${JSON.stringify(tagsMap)}`
367
- }
368
386
  },
369
387
  },
370
388
  ],
371
389
  },
372
390
  })
373
391
 
374
- // Use configurable routeBasePath for multi-instance support
375
- const basePath = blogConfig.routeBasePath
392
+ const basePath = normalizedBasePath
393
+ const pageBase = '@levino/shipyard-blog/astro/pages'
376
394
 
377
395
  if (config.i18n) {
378
- // With i18n: use locale prefix
379
396
  injectRoute({
380
397
  pattern: `/[locale]/${basePath}`,
381
- entrypoint: `@levino/shipyard-blog/astro/BlogIndex.astro`,
398
+ entrypoint: `${pageBase}/BlogIndex.astro`,
382
399
  })
383
400
  injectRoute({
384
401
  pattern: `/[locale]/${basePath}/page/[page]`,
385
- entrypoint: `@levino/shipyard-blog/astro/BlogIndexPaginated.astro`,
402
+ entrypoint: `${pageBase}/BlogIndexPaginated.astro`,
386
403
  })
387
404
  injectRoute({
388
405
  pattern: `/[locale]/${basePath}/tags`,
389
- entrypoint: `@levino/shipyard-blog/astro/BlogTagsIndex.astro`,
406
+ entrypoint: `${pageBase}/BlogTagsIndex.astro`,
390
407
  })
391
408
  injectRoute({
392
409
  pattern: `/[locale]/${basePath}/tags/[tag]`,
393
- entrypoint: `@levino/shipyard-blog/astro/BlogTagPage.astro`,
410
+ entrypoint: `${pageBase}/BlogTagPage.astro`,
394
411
  })
395
412
  if (blogConfig.archiveEnabled) {
396
413
  injectRoute({
397
414
  pattern: `/[locale]/${basePath}/archive`,
398
- entrypoint: `@levino/shipyard-blog/astro/BlogArchive.astro`,
415
+ entrypoint: `${pageBase}/BlogArchive.astro`,
399
416
  })
400
417
  }
401
418
  if (blogConfig.authorsEnabled) {
402
419
  injectRoute({
403
420
  pattern: `/[locale]/${basePath}/authors`,
404
- entrypoint: `@levino/shipyard-blog/astro/BlogAuthorsIndex.astro`,
421
+ entrypoint: `${pageBase}/BlogAuthorsIndex.astro`,
405
422
  })
406
423
  injectRoute({
407
424
  pattern: `/[locale]/${basePath}/authors/[author]`,
408
- entrypoint: `@levino/shipyard-blog/astro/BlogAuthorPage.astro`,
425
+ entrypoint: `${pageBase}/BlogAuthorPage.astro`,
409
426
  })
410
427
  }
411
428
  injectRoute({
412
429
  pattern: `/[locale]/${basePath}/[...slug]`,
413
- entrypoint: `@levino/shipyard-blog/astro/BlogEntry.astro`,
430
+ entrypoint: `${pageBase}/BlogEntry.astro`,
414
431
  })
415
432
  } else {
416
- // Without i18n: direct path
417
433
  injectRoute({
418
434
  pattern: `/${basePath}`,
419
- entrypoint: `@levino/shipyard-blog/astro/BlogIndex.astro`,
435
+ entrypoint: `${pageBase}/BlogIndex.astro`,
420
436
  })
421
437
  injectRoute({
422
438
  pattern: `/${basePath}/page/[page]`,
423
- entrypoint: `@levino/shipyard-blog/astro/BlogIndexPaginated.astro`,
439
+ entrypoint: `${pageBase}/BlogIndexPaginated.astro`,
424
440
  })
425
441
  injectRoute({
426
442
  pattern: `/${basePath}/tags`,
427
- entrypoint: `@levino/shipyard-blog/astro/BlogTagsIndex.astro`,
443
+ entrypoint: `${pageBase}/BlogTagsIndex.astro`,
428
444
  })
429
445
  injectRoute({
430
446
  pattern: `/${basePath}/tags/[tag]`,
431
- entrypoint: `@levino/shipyard-blog/astro/BlogTagPage.astro`,
447
+ entrypoint: `${pageBase}/BlogTagPage.astro`,
432
448
  })
433
449
  if (blogConfig.archiveEnabled) {
434
450
  injectRoute({
435
451
  pattern: `/${basePath}/archive`,
436
- entrypoint: `@levino/shipyard-blog/astro/BlogArchive.astro`,
452
+ entrypoint: `${pageBase}/BlogArchive.astro`,
437
453
  })
438
454
  }
439
455
  if (blogConfig.authorsEnabled) {
440
456
  injectRoute({
441
457
  pattern: `/${basePath}/authors`,
442
- entrypoint: `@levino/shipyard-blog/astro/BlogAuthorsIndex.astro`,
458
+ entrypoint: `${pageBase}/BlogAuthorsIndex.astro`,
443
459
  })
444
460
  injectRoute({
445
461
  pattern: `/${basePath}/authors/[author]`,
446
- entrypoint: `@levino/shipyard-blog/astro/BlogAuthorPage.astro`,
462
+ entrypoint: `${pageBase}/BlogAuthorPage.astro`,
447
463
  })
448
464
  }
449
465
  injectRoute({
450
466
  pattern: `/${basePath}/[...slug]`,
451
- entrypoint: `@levino/shipyard-blog/astro/BlogEntry.astro`,
467
+ entrypoint: `${pageBase}/BlogEntry.astro`,
452
468
  })
453
469
  }
454
470
 
455
- // Inject feed routes if enabled
456
471
  const { feedOptions } = blogConfig
457
- if (feedOptions.rss || feedOptions.atom || feedOptions.json) {
458
- if (config.i18n) {
459
- injectRoute({
460
- pattern: `/[locale]/${basePath}/rss.xml`,
461
- entrypoint: `@levino/shipyard-blog/astro/feeds/rss.xml.ts`,
462
- })
463
- injectRoute({
464
- pattern: `/[locale]/${basePath}/atom.xml`,
465
- entrypoint: `@levino/shipyard-blog/astro/feeds/atom.xml.ts`,
466
- })
467
- injectRoute({
468
- pattern: `/[locale]/${basePath}/feed.json`,
469
- entrypoint: `@levino/shipyard-blog/astro/feeds/feed.json.ts`,
470
- })
471
- } else {
472
- injectRoute({
473
- pattern: `/${basePath}/rss.xml`,
474
- entrypoint: `@levino/shipyard-blog/astro/feeds/rss.xml.ts`,
475
- })
476
- injectRoute({
477
- pattern: `/${basePath}/atom.xml`,
478
- entrypoint: `@levino/shipyard-blog/astro/feeds/atom.xml.ts`,
479
- })
480
- injectRoute({
481
- pattern: `/${basePath}/feed.json`,
482
- entrypoint: `@levino/shipyard-blog/astro/feeds/feed.json.ts`,
483
- })
484
- }
472
+ if (feedOptions.rss) {
473
+ injectRoute({
474
+ pattern: config.i18n
475
+ ? `/[locale]/${basePath}/rss.xml`
476
+ : `/${basePath}/rss.xml`,
477
+ entrypoint: `${pageBase}/feeds/rss.xml.ts`,
478
+ })
479
+ }
480
+ if (feedOptions.atom) {
481
+ injectRoute({
482
+ pattern: config.i18n
483
+ ? `/[locale]/${basePath}/atom.xml`
484
+ : `/${basePath}/atom.xml`,
485
+ entrypoint: `${pageBase}/feeds/atom.xml.ts`,
486
+ })
487
+ }
488
+ if (feedOptions.json) {
489
+ injectRoute({
490
+ pattern: config.i18n
491
+ ? `/[locale]/${basePath}/feed.json`
492
+ : `/${basePath}/feed.json`,
493
+ entrypoint: `${pageBase}/feeds/feed.json.ts`,
494
+ })
485
495
  }
486
496
  },
487
497
  },