From 8206a23b56fc3b2c43018fb0cb570f6ba8931d52 Mon Sep 17 00:00:00 2001 From: David Peng Date: Mon, 22 Apr 2024 18:53:18 +0800 Subject: [PATCH] fix: atom missing authors (#383) --- .changeset/rare-shrimps-obey.md | 5 + packages/karbon/src/internal.ts | 1 + packages/karbon/src/runtime/api/feed.ts | 3 +- .../__tests__/__snapshots__/feed.spec.ts.snap | 72 ++++++++++++ .../src/runtime/lib/__tests__/feed.spec.ts | 82 +++++++++++++ packages/karbon/src/runtime/lib/feed.ts | 107 +++++++++++++++++ .../src/runtime/routes/atom-desk.xml.ts | 43 ++----- .../karbon/src/runtime/routes/atom.xml.ts | 111 +++++------------- 8 files changed, 308 insertions(+), 116 deletions(-) create mode 100644 .changeset/rare-shrimps-obey.md create mode 100644 packages/karbon/src/runtime/lib/__tests__/__snapshots__/feed.spec.ts.snap create mode 100644 packages/karbon/src/runtime/lib/__tests__/feed.spec.ts create mode 100644 packages/karbon/src/runtime/lib/feed.ts diff --git a/.changeset/rare-shrimps-obey.md b/.changeset/rare-shrimps-obey.md new file mode 100644 index 00000000..6d625945 --- /dev/null +++ b/.changeset/rare-shrimps-obey.md @@ -0,0 +1,5 @@ +--- +'@storipress/karbon': patch +--- + +fix: missing author in atom feed diff --git a/packages/karbon/src/internal.ts b/packages/karbon/src/internal.ts index 4f7e077d..b0fcfc5a 100644 --- a/packages/karbon/src/internal.ts +++ b/packages/karbon/src/internal.ts @@ -16,3 +16,4 @@ export { getDesk, listDesks } from './runtime/api/desk' export { getTag, listTags } from './runtime/api/tag' export { getAuthor, listAuthors } from './runtime/api/author' export { getResources, payloadScopes } from './runtime/api/sitemap' +export * from './runtime/lib/feed' diff --git a/packages/karbon/src/runtime/api/feed.ts b/packages/karbon/src/runtime/api/feed.ts index 4ef62878..a20539c2 100644 --- a/packages/karbon/src/runtime/api/feed.ts +++ b/packages/karbon/src/runtime/api/feed.ts @@ -1,5 +1,6 @@ import { gql } from '@apollo/client/core/index.js' import { useStoripressClient } from '../composables/storipress-client' +import type { _NormalizeArticle } from './normalize-article' const GetDesk = gql` query GetDesk($slug: String) { @@ -21,7 +22,7 @@ const GetDesk = gql` } ` -export async function listFeedArticles() { +export async function listFeedArticles(): Promise<_NormalizeArticle[]> { const allArticles = (await $fetch('/_storipress/posts/__all.json')) ?? [] return allArticles } diff --git a/packages/karbon/src/runtime/lib/__tests__/__snapshots__/feed.spec.ts.snap b/packages/karbon/src/runtime/lib/__tests__/__snapshots__/feed.spec.ts.snap new file mode 100644 index 00000000..267d7798 --- /dev/null +++ b/packages/karbon/src/runtime/lib/__tests__/__snapshots__/feed.spec.ts.snap @@ -0,0 +1,72 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`addFeedPageLinks > can add feed page links 1`] = ` +" + + https://example.com/ + site name + 2024-04-24T00:00:00.000Z + https://github.com/jpmonette/feed + + + + + site description + © site name 2024 All Rights Reserved + + + <![CDATA[title]]> + + https://example.com/post + + 2024-04-24T00:00:00.000Z + + +" +`; + +exports[`createFeed > can create feed with default config 1`] = ` +" + + https://example.com/ + site name + 2024-04-24T00:00:00.000Z + https://github.com/jpmonette/feed + + + site description + © site name 2024 All Rights Reserved + + <![CDATA[title]]> + https://example.com/post + + 2024-04-24T00:00:00.000Z + +" +`; + +exports[`generateAtomFeed > can generate atom feed 1`] = ` +" + + https://example.com/ + site name + 2024-04-24T00:00:00.000Z + https://github.com/jpmonette/feed + + + site description + © site name 2024 All Rights Reserved + + <![CDATA[title]]> + https://example.com/posts/slug + + 2024-04-24T00:00:00.000Z + + + + author + + 2024-04-24T00:00:00.000Z + +" +`; diff --git a/packages/karbon/src/runtime/lib/__tests__/feed.spec.ts b/packages/karbon/src/runtime/lib/__tests__/feed.spec.ts new file mode 100644 index 00000000..4157c09f --- /dev/null +++ b/packages/karbon/src/runtime/lib/__tests__/feed.spec.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { addFeedPageLinks, createFeed, generateAtomFeed } from '../feed' + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2024-04-24T00:00:00.000Z')) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('createFeed', () => { + it('can create feed with default config', () => { + const feed = createFeed({ + siteName: 'site name', + siteDescription: 'site description', + siteUrl: 'https://example.com', + feedUrl: 'atom.xml', + }) + + feed.addItem({ + title: 'title', + date: new Date('2024-04-24T00:00:00.000Z'), + link: 'https://example.com/post', + }) + + expect(feed.atom1()).toMatchSnapshot() + }) +}) + +describe('generateAtomFeed', () => { + it('can generate atom feed', () => { + const atom = generateAtomFeed({ + articles: [ + { + id: '1', + slug: 'slug', + authors: [{ name: 'author' }] as any, + title: 'title', + html: 'html', + plaintext: 'plaintext', + updated_at: '2024-04-24T00:00:00.000Z', + published_at: '2024-04-24T00:00:00.000Z', + }, + ], + getArticleURL: () => '/posts/slug', + siteName: 'site name', + siteDescription: 'site description', + siteUrl: 'https://example.com', + feedUrl: 'atom.xml', + }) + + expect(atom).toMatchSnapshot() + }) +}) + +const ATOM_FIXTURE = ` + + + https://example.com/ + site name + 2024-04-24T00:00:00.000Z + https://github.com/jpmonette/feed + + + site description + © site name 2024 All Rights Reserved + + <![CDATA[title]]> + https://example.com/post + + 2024-04-24T00:00:00.000Z + + +` + +describe('addFeedPageLinks', () => { + it('can add feed page links', () => { + expect(addFeedPageLinks(ATOM_FIXTURE, 'https://example.com', 2, 3)).toMatchSnapshot() + }) +}) diff --git a/packages/karbon/src/runtime/lib/feed.ts b/packages/karbon/src/runtime/lib/feed.ts new file mode 100644 index 00000000..7a6f55d4 --- /dev/null +++ b/packages/karbon/src/runtime/lib/feed.ts @@ -0,0 +1,107 @@ +import { Feed } from 'feed' +import { encodePath, joinURL, withQuery, withTrailingSlash, withoutTrailingSlash } from 'ufo' +import { XMLBuilder, XMLParser } from 'fast-xml-parser' +import type { _NormalizeArticle } from '../api/normalize-article' + +export function createFeed({ + siteUrl, + siteName, + siteDescription, + feedUrl, +}: { + siteUrl: string + siteName: string + siteDescription: string + feedUrl: string +}) { + return new Feed({ + id: withTrailingSlash(siteUrl), + link: withTrailingSlash(siteUrl), + title: siteName, + description: siteDescription, + updated: new Date(), + feedLinks: { + atom: joinURL(siteUrl, feedUrl), + }, + copyright: `© ${siteName} ${new Date().getFullYear()} All Rights Reserved`, + }) +} + +export function addFeedPageLinks(atomXml: string, siteUrl: string, currentPage: number, maxPage: number): string { + const option = { + ignoreAttributes: false, + attributeNamePrefix: '@_', + cdataPropName: '__cdata', + format: true, + } + const parser = new XMLParser(option) + const builder = new XMLBuilder(option) + + const atomJson = parser.parse(atomXml) + + const rssUrl = `${withoutTrailingSlash(siteUrl)}/atom.xml` + const previousLink = + currentPage > 1 ? [{ '@_rel': 'previous', '@_href': withQuery(rssUrl, { page: currentPage - 1 }) }] : [] + const nextLink = + currentPage < maxPage ? [{ '@_rel': 'next', '@_href': withQuery(rssUrl, { page: currentPage + 1 }) }] : [] + + const buildAtomXml = builder.build({ + ...atomJson, + feed: { + ...atomJson.feed, + link: [...atomJson.feed.link, ...previousLink, ...nextLink], + }, + }) + return buildAtomXml +} + +export type FeedArticle = Pick< + _NormalizeArticle, + 'id' | 'slug' | 'plaintext' | 'authors' | 'updated_at' | 'published_at' | 'title' | 'html' +> + +export interface GenerateAtomFeedInput { + articles: FeedArticle[] + siteUrl: string + siteName: string + siteDescription: string + feedUrl: string + getArticleURL: (article: FeedArticle) => string +} + +export function generateAtomFeed({ + articles, + siteUrl, + siteName, + siteDescription, + feedUrl, + getArticleURL, +}: GenerateAtomFeedInput): string { + const feed = createFeed({ + siteUrl, + siteName, + siteDescription, + feedUrl, + }) + + articles + .filter((article) => article.published_at) + .forEach((article) => { + const id = encodePath(getArticleURL(article)) + feed.addItem({ + title: article.title, + id: joinURL(siteUrl, id), + link: joinURL(siteUrl, id), + description: article.plaintext.slice(0, 120), + date: new Date(article.updated_at), + published: new Date(article.published_at), + author: + article.authors?.map((author) => ({ + name: author.name, + })) || [], + content: article.html, + }) + }) + + return feed.atom1() +} diff --git a/packages/karbon/src/runtime/routes/atom-desk.xml.ts b/packages/karbon/src/runtime/routes/atom-desk.xml.ts index 3ac38deb..645c7542 100644 --- a/packages/karbon/src/runtime/routes/atom-desk.xml.ts +++ b/packages/karbon/src/runtime/routes/atom-desk.xml.ts @@ -1,8 +1,6 @@ import { defineEventHandler, sendNoContent, setHeader } from 'h3' -import { Feed } from 'feed' -import { encodePath, joinURL, withTrailingSlash } from 'ufo' import path from 'pathe' -import { getDeskWithSlug, listArticles } from '@storipress/karbon/internal' +import { generateAtomFeed, getDeskWithSlug, listArticles } from '@storipress/karbon/internal' import { useRuntimeConfig } from '#imports' import urls from '#sp-internal/storipress-urls.mjs' @@ -21,39 +19,20 @@ export default defineEventHandler(async (e) => { return sendNoContent(e, 404) } - const deskIds: string[] = desk.desks?.map(({ id }: { id: string }) => id) ?? [] + const subDesks: string[] = desk.desks?.map(({ id }: { id: string }) => id) ?? [] const runtimeConfig = useRuntimeConfig() - const articles = await listArticles({ desk_ids: deskIds }) + const articles = await listArticles({ desk_ids: [desk.id, ...subDesks] }) const siteUrl = runtimeConfig.public.siteUrl as string - const feed = new Feed({ - id: withTrailingSlash(runtimeConfig.public.siteUrl as string), - link: withTrailingSlash(runtimeConfig.public.siteUrl as string), - title: runtimeConfig.public.siteName as string, - description: runtimeConfig.public.siteDescription as string, - updated: new Date(), - feedLinks: { - atom: joinURL(siteUrl, `/atom/${fileName}`), - }, - copyright: `© ${runtimeConfig.public.siteName} ${new Date().getFullYear()} All Rights Reserved`, - }) - articles.forEach((article) => { - const id = encodePath(urls.article.toURL(article, urls.article._context)) - feed.addItem({ - title: article.title, - id: joinURL(siteUrl, id), - link: joinURL(siteUrl, id), - description: article.plaintext.slice(0, 120), - date: new Date(article.published_at), - author: - article.authors?.map((author) => ({ - name: author.name, - })) || [], - content: article.html, - }) + const atomXml = generateAtomFeed({ + articles, + siteUrl, + siteName: runtimeConfig.public.siteName as string, + siteDescription: runtimeConfig.public.siteDescription as string, + feedUrl: `/atom/${fileName}`, + getArticleURL: (article) => urls.article.toURL(article, urls.article._context), }) - - return feed.atom1() + return atomXml }) diff --git a/packages/karbon/src/runtime/routes/atom.xml.ts b/packages/karbon/src/runtime/routes/atom.xml.ts index f89c5709..837be9b5 100644 --- a/packages/karbon/src/runtime/routes/atom.xml.ts +++ b/packages/karbon/src/runtime/routes/atom.xml.ts @@ -1,97 +1,42 @@ +import type { H3Event } from 'h3' import { defineEventHandler, getQuery, setHeader } from 'h3' -import { Feed } from 'feed' -import { XMLBuilder, XMLParser } from 'fast-xml-parser' -import { encodePath, joinURL, withQuery, withTrailingSlash, withoutTrailingSlash } from 'ufo' -import { listFeedArticles } from '@storipress/karbon/internal' -import type { Author } from '../composables/page-meta' +import type { FeedArticle } from '@storipress/karbon/internal' +import { addFeedPageLinks, generateAtomFeed, listFeedArticles } from '@storipress/karbon/internal' +import type { _NormalizeArticle } from '../api/normalize-article' import { useRuntimeConfig } from '#imports' import urls from '#sp-internal/storipress-urls.mjs' -interface TArticle { - title: string - id: string - link: string - description: string - content: string - date: Date - author: Author[] - plaintext: string - html: string - published_at: string - updated_at: string -} - -export default defineEventHandler(async (e) => { - setHeader(e, 'Content-Type', 'text/xml; charset=UTF-8') - if (!process.dev) setHeader(e, 'Cache-Control', 'max-age=600, must-revalidate') +export default defineEventHandler(async (event) => { + setHeader(event, 'Content-Type', 'text/xml; charset=UTF-8') + if (!process.dev) setHeader(event, 'Cache-Control', 'max-age=600, must-revalidate') const runtimeConfig = useRuntimeConfig() - const articles = await listFeedArticles() - const siteUrl = runtimeConfig.public.siteUrl as string - const feed = new Feed({ - id: withTrailingSlash(siteUrl), - link: withTrailingSlash(siteUrl), - title: runtimeConfig.public.siteName as string, - description: runtimeConfig.public.siteDescription as string, - updated: new Date(), - feedLinks: { - atom: joinURL(siteUrl, 'atom.xml'), - }, - copyright: `© ${runtimeConfig.public.siteName} ${new Date().getFullYear()} All Rights Reserved`, - }) - const ARTICLES_PER_PAGE = Number(process.env.NUXT_KARBON_RSS_PAGE_COUNT) || 100 - const queryString = getQuery(e) - const page = Number(queryString.page) || 1 - const maxPage = Math.ceil(articles.length / ARTICLES_PER_PAGE) - const currentPage = page > maxPage ? maxPage : page - const currentPageArticles = articles.slice((currentPage - 1) * ARTICLES_PER_PAGE, currentPage * ARTICLES_PER_PAGE) + const { currentPageArticles, currentPage, maxPage } = paginateArticle(event, await listFeedArticles()) - currentPageArticles - .filter((article: TArticle) => article.published_at) - .forEach((article: TArticle) => { - const id = encodePath(urls.article.toURL(article, urls.article._context)) - feed.addItem({ - title: article.title, - id: joinURL(siteUrl, id), - link: joinURL(siteUrl, id), - description: article.plaintext.slice(0, 120), - date: new Date(article.updated_at), - published: new Date(article.published_at), - author: - article.author?.map((author) => ({ - name: author.name, - })) || [], - content: article.html, - }) - }) + const atomXml = generateAtomFeed({ + articles: currentPageArticles, + siteUrl, + siteName: runtimeConfig.public.siteName as string, + siteDescription: runtimeConfig.public.siteDescription as string, + feedUrl: 'atom.xml', + getArticleURL: (article) => urls.article.toURL(article, urls.article._context), + }) - const atomXml = feed.atom1() + const buildAtomXml = addFeedPageLinks(atomXml, siteUrl, currentPage, maxPage) - const option = { - ignoreAttributes: false, - attributeNamePrefix: '@_', - cdataPropName: '__cdata', - format: true, - } - const parser = new XMLParser(option) - const builder = new XMLBuilder(option) + return buildAtomXml +}) - const atomJson = parser.parse(atomXml) +const ARTICLES_PER_PAGE = 100 - const rssUrl = `${withoutTrailingSlash(siteUrl)}/atom.xml` - const previousLink = - currentPage > 1 ? [{ '@_rel': 'previous', '@_href': withQuery(rssUrl, { page: currentPage - 1 }) }] : [] - const nextLink = - currentPage < maxPage ? [{ '@_rel': 'next', '@_href': withQuery(rssUrl, { page: currentPage + 1 }) }] : [] +function paginateArticle(event: H3Event, articles: FeedArticle[]) { + const queryString = getQuery(event) + const page = Number(queryString.page) || 1 + const maxPage = Math.ceil(articles.length / ARTICLES_PER_PAGE) + const currentPage = page > maxPage ? maxPage : page + const currentPageArticles = articles.slice((currentPage - 1) * ARTICLES_PER_PAGE, currentPage * ARTICLES_PER_PAGE) - const buildAtomXml = builder.build({ - ...atomJson, - feed: { - ...atomJson.feed, - link: [...atomJson.feed.link, ...previousLink, ...nextLink], - }, - }) - return buildAtomXml -}) + return { currentPageArticles, currentPage, maxPage } +}