refactor: separate rss and atom generation, optimize back button logic (#22)

* refactor: separate rss and atom generation, optimize back button logic, remove redundant whitespace in component styles

* fix:  add missing <published> tag in atom feed
This commit is contained in:
radishzz 2025-05-10 04:37:20 +01:00 committed by GitHub
parent 4b05ba9caf
commit fc1cc050bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 576 additions and 418 deletions

View file

@ -69,128 +69,98 @@ document.addEventListener('astro:page-load', initWaline)
#waline .wl-login-info {
--at-apply: 'mt-0 mr-3'
}
#waline .wl-avatar {
--at-apply: 'border-none'
}
#waline .wl-logout-btn {
--at-apply: 'z-99'
}
#waline .wl-login-nick:not(:has(img)) {
--at-apply: 'leading-3.6 mt-1.4';
}
#waline .wl-panel {
--at-apply: 'm-0 rounded border-secondary/25'
}
#waline .wl-header {
--at-apply: 'p-0';
}
#waline .wl-header-item {
border-bottom: 1px solid var(--waline-border-color);
}
#waline .wl-header label {
--at-apply: 'text-3';
}
#waline .wl-header input {
--at-apply: 'text-2.8';
}
#waline .wl-card,
#waline .wl-header.item3 {
--at-apply: 'border-b-0';
}
#waline .wl-card .wl-quote {
--at-apply: 'border-is-none mt-4';
}
#waline .wl-editor {
--at-apply: 'min-h-24';
}
#waline .wl-editor::placeholder {
color: var(--waline-light-grey);
}
#waline .wl-footer {
--at-apply: 'm-2';
}
#waline .wl-info .wl-btn {
--at-apply: 'rounded';
}
#waline .wl-text-number,
#waline .wl-action[title="Markdown Guide"],
#waline .wl-sort,
#waline .wl-gallery::-webkit-scrollbar {
--at-apply: 'hidden';
}
#waline .wl-emoji-popup {
--at-apply: 'start-0 rounded border-secondary/25';
}
#waline .wl-emoji-popup .wl-tab-wrapper::-webkit-scrollbar {
--at-apply: 'w-1.2';
}
#waline .wl-emoji-popup .wl-tab-wrapper::-webkit-scrollbar-thumb {
background: oklch(var(--un-preset-theme-colors-secondary) / 0.25);
}
#waline .wl-emoji-popup .wl-tab-wrapper::-webkit-scrollbar-track-piece {
--at-apply: 'bg-transparent';
}
#waline .wl-gif-popup {
--at-apply: 'border-secondary/25';
}
#waline .wl-gif-popup input {
--at-apply: 'bg-background border-secondary/25';
}
#waline .wl-gif-popup input::placeholder {
--at-apply: 'c-secondary/30 text-3.5';
}
#waline .wl-gallery {
--at-apply: 'scrollbar-hidden';
}
#waline .wl-meta-head {
--at-apply: 'pt-3 pb-2 px-0';
}
#waline .wl-card-item {
--at-apply: 'px-0';
}
#waline .wl-user-avatar {
--at-apply: 'mt-1';
}
#waline .wl-content p {
--at-apply: 'leading-6 text-3.5';
}
#waline .wl-time {
color: oklch(var(--un-preset-theme-colors-primary) / 0.75);
}
#waline .wl-edit,
#waline .wl-delete {
--at-apply: 'mr-0.4';
}
#waline .wl-like {
--at-apply: 'mr-1.2';
}
@ -199,33 +169,33 @@ document.addEventListener('astro:page-load', initWaline)
<!-- Official CSS Variables >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> -->
<!-- https://waline.js.org/reference/client/style.html -->
<style>
#waline {
/* Regular Colors */
--waline-white: var(--uno-colors-background);
--waline-light-grey: oklch(var(--un-preset-theme-colors-primary) / 0.25);
--waline-dark-grey: var(--uno-colors-secondary);
#waline {
/* Regular Colors */
--waline-white: var(--uno-colors-background);
--waline-light-grey: oklch(var(--un-preset-theme-colors-primary) / 0.25);
--waline-dark-grey: var(--uno-colors-secondary);
/* Theme Colors */
--waline-theme-color: var(--uno-colors-primary);
--waline-active-color: var(--uno-colors-primary);
/* Theme Colors */
--waline-theme-color: var(--uno-colors-primary);
--waline-active-color: var(--uno-colors-primary);
/* Layout Colors */
--waline-color: var(--uno-colors-secondary);
--waline-bg-color: var(--uno-colors-background);
--waline-bg-color-light: var(--uno-colors-background);
--waline-bg-color-hover: var(--uno-colors-background);
--waline-border-color: oklch(var(--un-preset-theme-colors-primary) / 0.25);
--waline-disable-bg-color: oklch(var(--un-preset-theme-colors-secondary) / 0.05);
--waline-disable-color: var(--uno-colors-primary);
/* Layout Colors */
--waline-color: var(--uno-colors-secondary);
--waline-bg-color: var(--uno-colors-background);
--waline-bg-color-light: var(--uno-colors-background);
--waline-bg-color-hover: var(--uno-colors-background);
--waline-border-color: oklch(var(--un-preset-theme-colors-primary) / 0.25);
--waline-disable-bg-color: oklch(var(--un-preset-theme-colors-secondary) / 0.05);
--waline-disable-color: var(--uno-colors-primary);
/* Special Colors */
--waline-bq-color: oklch(var(--un-preset-theme-colors-primary) / 0.25);
/* Special Colors */
--waline-bq-color: oklch(var(--un-preset-theme-colors-primary) / 0.25);
/* Information */
--waline-info-bg-color: var(--uno-colors-background);
--waline-info-color: oklch(var(--un-preset-theme-colors-primary) / 0.25);
/* Information */
--waline-info-bg-color: var(--uno-colors-background);
--waline-info-color: oklch(var(--un-preset-theme-colors-primary) / 0.25);
/* Rendering Options */
--waline-avatar-radius: 0.5rem;
}
</style>
/* Rendering Options */
--waline-avatar-radius: 0.5rem;
}
</style>

View file

@ -6,7 +6,7 @@ import GoBackIcon from '@/assets/icons/go-back.svg';
id="back-button"
class="hidden"
lg="block absolute c-secondary/40 left--10 top-1/2 aspect-square w-4.5 translate-y--1/2 transition-colors ease-out c-secondary active:scale-90 hover:c-primary/80"
aria-label="Back to home"
aria-label="Go back"
>
<GoBackIcon
aria-hidden="true"
@ -21,16 +21,9 @@ function setupBackButton() {
if (window.history.length > 1) {
window.history.back()
}
else {
// Click site title link to trigger view transition when no history
const titleLink = document.getElementById('site-title-link') as HTMLAnchorElement
if (titleLink) {
titleLink.click()
}
}
})
}
setupBackButton()
document.addEventListener('astro:page-load', setupBackButton)
document.addEventListener('astro:after-swap', setupBackButton)
</script>

View file

@ -82,11 +82,9 @@ const filteredHeadings = headings.filter(heading =>
.toc-title {
--at-apply: 'font-semibold ml-4 select-none 2xl:hidden';
}
.toc-list {
--at-apply: 'list-none pl-0 space-y-2 mt-1 mb-4 2xl:space-y-1.2';
}
.toc-link-h2, .toc-link-h3, .toc-link-h4 {
--at-apply: 'text-sm no-underline font-normal text-balance select-none 2xl:(text-3.2 c-secondary/60 transition-colors ease-in hover:(c-secondary font-medium))';
}
@ -95,7 +93,6 @@ const filteredHeadings = headings.filter(heading =>
.accordion-wrapper {
--at-apply: 'grid rows-[0fr] duration-300 ease-in-out';
}
.accordion-content {
--at-apply: 'overflow-hidden max-h-66 2xl:(max-h-[calc(100vh-21.5rem)]) pl-4 pr-6';
}
@ -104,7 +101,6 @@ const filteredHeadings = headings.filter(heading =>
.accordion-toggle:checked ~ .accordion-wrapper {
grid-template-rows: 1fr;
}
.accordion-toggle:checked ~ .accordion-wrapper .accordion-content {
--at-apply: 'overflow-y-auto';
}
@ -113,19 +109,15 @@ const filteredHeadings = headings.filter(heading =>
.accordion-wrapper {
grid-template-rows: 1fr;
}
.accordion-toggle:checked ~ .accordion-wrapper {
grid-template-rows: 0fr;
}
.accordion-content {
--at-apply: 'overflow-y-auto';
}
.accordion-toggle:checked ~ .accordion-wrapper .accordion-content {
--at-apply: 'overflow-hidden';
}
.toc-link-active {
--at-apply: 'c-secondary font-medium';
}

View file

@ -1,6 +1,6 @@
import type { APIContext } from 'astro'
import { moreLocales } from '@/config'
import { generateRSS } from '@/utils/rss'
import { generateAtom } from '@/utils/feed'
export function getStaticPaths() {
return moreLocales.map(lang => ({
@ -8,7 +8,6 @@ export function getStaticPaths() {
}))
}
export async function GET({ params }: APIContext) {
const lang = params.lang as typeof moreLocales[number]
return generateRSS({ lang })
export async function GET(context: APIContext) {
return generateAtom(context)
}

View file

@ -1,6 +1,6 @@
import type { APIContext } from 'astro'
import { moreLocales } from '@/config'
import { generateRSS } from '@/utils/rss'
import { generateRSS } from '@/utils/feed'
export function getStaticPaths() {
return moreLocales.map(lang => ({
@ -8,7 +8,6 @@ export function getStaticPaths() {
}))
}
export async function GET({ params }: APIContext) {
const lang = params.lang as typeof moreLocales[number]
return generateRSS({ lang })
export async function GET(context: APIContext) {
return generateRSS(context)
}

View file

@ -1,5 +1,6 @@
import { generateRSS } from '@/utils/rss'
import type { APIContext } from 'astro'
import { generateAtom } from '@/utils/feed'
export async function GET() {
return generateRSS()
export async function GET(context: APIContext) {
return generateAtom(context)
}

View file

@ -1,5 +1,6 @@
import { generateRSS } from '@/utils/rss'
import type { APIContext } from 'astro'
import { generateRSS } from '@/utils/feed'
export async function GET() {
return generateRSS()
export async function GET(context: APIContext) {
return generateRSS(context)
}

View file

@ -2,7 +2,7 @@ import type { CollectionEntry } from 'astro:content'
import { defaultLocale } from '@/config'
import MarkdownIt from 'markdown-it'
type ExcerptScene = 'list' | 'meta' | 'og' | 'rss'
type ExcerptScene = 'list' | 'meta' | 'og' | 'feed'
const parser = new MarkdownIt()
const isCJKLang = (lang: string) => ['zh', 'zh-tw', 'ja'].includes(lang)
@ -24,7 +24,7 @@ const EXCERPT_LENGTHS: Record<ExcerptScene, {
cjk: 70,
other: 140,
},
rss: {
feed: {
cjk: 70,
other: 140,
},

155
src/utils/feed.ts Normal file
View file

@ -0,0 +1,155 @@
import type { APIContext } from 'astro'
import type { CollectionEntry } from 'astro:content'
import type { Author } from 'feed'
import { defaultLocale, themeConfig } from '@/config'
import { ui } from '@/i18n/ui'
import { generateDescription } from '@/utils/description'
import { getCollection } from 'astro:content'
import { Feed } from 'feed'
import MarkdownIt from 'markdown-it'
import sanitizeHtml from 'sanitize-html'
const markdownParser = new MarkdownIt()
const { title, description, url, author: siteAuthor } = themeConfig.site
const followConfig = themeConfig.seo?.follow
interface GenerateFeedOptions {
lang?: string
}
/**
* Generate post URL with language prefix and abbrlink/slug
*/
function generatePostUrl(post: CollectionEntry<'posts'>, baseUrl: string): string {
const needsLangPrefix = post.data.lang !== defaultLocale && post.data.lang !== ''
const langPrefix = needsLangPrefix ? `${post.data.lang}/` : ''
const postSlug = post.data.abbrlink || post.id
return new URL(`${langPrefix}posts/${postSlug}/`, baseUrl).toString()
}
/**
* Generate a feed object supporting both RSS and Atom formats
*/
export async function generateFeed({ lang }: GenerateFeedOptions = {}) {
const currentUI = ui[lang as keyof typeof ui] || ui[defaultLocale as keyof typeof ui]
const useI18nTitle = themeConfig.site.i18nTitle
const siteTitle = useI18nTitle ? currentUI.title : title
const siteDescription = useI18nTitle ? currentUI.description : description
const siteURL = lang ? `${url}/${lang}` : url
const author: Author = {
name: siteAuthor,
link: url,
}
// Create Feed instance
const feed = new Feed({
title: siteTitle,
description: siteDescription,
id: siteURL,
link: siteURL,
language: lang || themeConfig.global.locale,
copyright: `Copyright © ${new Date().getFullYear()} ${siteAuthor}`,
updated: new Date(),
generator: 'Astro-Theme-Retypeset with Feed for Node.js',
feedLinks: {
rss: new URL(lang ? `/${lang}/rss.xml` : '/rss.xml', url).toString(),
atom: new URL(lang ? `/${lang}/atom.xml` : '/atom.xml', url).toString(),
},
author,
})
// Filter posts by language and exclude drafts
const posts = await getCollection(
'posts',
({ data }: { data: CollectionEntry<'posts'>['data'] }) =>
(!data.draft && (data.lang === lang || data.lang === '' || (lang === undefined && data.lang === defaultLocale))),
)
// Sort posts by published date in descending order
const sortedPosts = [...posts].sort((a, b) =>
new Date(b.data.published).getTime() - new Date(a.data.published).getTime(),
)
// Limit to the latest 25 posts
const limitedPosts = sortedPosts.slice(0, 25)
// Add posts to feed
for (const post of limitedPosts) {
const postLink = generatePostUrl(post, url)
const postContent = post.body
? sanitizeHtml(markdownParser.render(post.body), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
})
: ''
feed.addItem({
title: post.data.title,
id: postLink,
link: postLink,
description: generateDescription(post, 'feed'),
content: postContent,
author: [author],
// published -> Atom:<published>, RSS:<pubDate>
published: new Date(post.data.published),
// date -> Atom:<updated>, RSS has no update tag
date: post.data.updated ? new Date(post.data.updated) : new Date(post.data.published),
})
}
// Add follow verification if available
if (followConfig?.feedID && followConfig?.userID) {
feed.addExtension({
name: 'follow_challenge',
objects: {
feedId: followConfig.feedID,
userId: followConfig.userID,
},
})
}
return feed
}
/**
* Generate RSS 2.0 format feed
*/
export async function generateRSS(context: APIContext) {
const feed = await generateFeed({
lang: context.params?.lang as string | undefined,
})
let rssXml = feed.rss2()
rssXml = rssXml.replace(
'<?xml version="1.0" encoding="utf-8"?>',
'<?xml version="1.0" encoding="utf-8"?>\n<?xml-stylesheet href="/feeds/rss-style.xsl" type="text/xsl"?>',
)
return new Response(rssXml, {
headers: {
'Content-Type': 'application/rss+xml; charset=utf-8',
},
})
}
/**
* Generate Atom 1.0 format feed
*/
export async function generateAtom(context: APIContext) {
const feed = await generateFeed({
lang: context.params?.lang as string | undefined,
})
let atomXml = feed.atom1()
atomXml = atomXml.replace(
'<?xml version="1.0" encoding="utf-8"?>',
'<?xml version="1.0" encoding="utf-8"?>\n<?xml-stylesheet href="/feeds/atom-style.xsl" type="text/xsl"?>',
)
return new Response(atomXml, {
headers: {
'Content-Type': 'application/atom+xml; charset=utf-8',
},
})
}

View file

@ -1,68 +0,0 @@
import type { CollectionEntry } from 'astro:content'
import { defaultLocale, themeConfig } from '@/config'
import { ui } from '@/i18n/ui'
import { generateDescription } from '@/utils/description'
import rss from '@astrojs/rss'
import { getCollection } from 'astro:content'
import MarkdownIt from 'markdown-it'
import sanitizeHtml from 'sanitize-html'
const parser = new MarkdownIt()
const { title, description, url } = themeConfig.site
const followConfig = themeConfig.seo?.follow
interface GenerateRSSOptions {
lang?: string
}
export async function generateRSS({ lang }: GenerateRSSOptions = {}) {
const currentUI = ui[lang as keyof typeof ui] || ui[defaultLocale as keyof typeof ui]
const siteTitle = themeConfig.site.i18nTitle ? currentUI.title : title
const siteDescription = themeConfig.site.i18nTitle ? currentUI.description : description
// Get posts for specific language (including universal posts and default language when lang is undefined)
const posts = await getCollection(
'posts',
({ data }: { data: CollectionEntry<'posts'>['data'] }) =>
(!data.draft && (data.lang === lang || data.lang === '' || (lang === undefined && data.lang === defaultLocale))),
)
// Sort posts by published date in descending order
const sortedPosts = [...posts].sort((a, b) =>
new Date(b.data.published).getTime() - new Date(a.data.published).getTime(),
)
return rss({
title: siteTitle,
site: lang ? `${url}/${lang}` : url,
description: siteDescription,
stylesheet: '/rss-style.xsl',
customData: `
<copyright>Copyright © ${new Date().getFullYear()} ${themeConfig.site.author}</copyright>
<language>${lang || themeConfig.global.locale}</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${followConfig?.feedID && followConfig?.userID
? `<follow_challenge>
<feedId>${followConfig.feedID}</feedId>
<userId>${followConfig.userID}</userId>
</follow_challenge>`
: ''
}
`.trim(),
items: sortedPosts.map((post: CollectionEntry<'posts'>) => ({
title: post.data.title,
// Generate URL with language prefix and abbrlink/slug
link: new URL(
`${post.data.lang !== defaultLocale && post.data.lang !== '' ? `${post.data.lang}/` : ''}posts/${post.data.abbrlink || post.id}/`,
url,
).toString(),
description: generateDescription(post, 'rss'),
pubDate: post.data.published,
content: post.body
? sanitizeHtml(parser.render(post.body), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
})
: '',
})),
})
}