Skip to content

Commit a5308d8

Browse files
linfangwclaude
andcommitted
Fix PR #3 review round 3: SITE_URL fallback, XSS escape, TechArticle, actual wordCount
Address all 9 Gemini review comments: 1-5. (MEDIUM) Consistent SITE_URL fallback: Added SITE_URL to consts.ts import in BaseHead.astro. All Astro.site usages now use a site fallback variable. Reordered canonicalUrl construction in BlogPost and DocPage to use the fallback. Listing page pageUrl also fixed. 6. (MEDIUM) JSON-LD XSS protection: escape < as \u003c in JSON.stringify output to prevent </script> injection. 7. (MEDIUM) Actual wordCount: reading-time utility now returns both minutes and wordCount via getReadingStats(). BlogPost passes the real count instead of a readingTime * N heuristic. 8. (MEDIUM) DocPage uses TechArticle schema instead of BlogPosting for better semantic accuracy on documentation pages. 9. (MEDIUM) JsonLd component now supports TechArticle as a schema type via the refactored buildArticle() function. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1d64c8f commit a5308d8

7 files changed

Lines changed: 47 additions & 20 deletions

File tree

src/components/BaseHead.astro

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import '../styles/global.css';
33
import type { ImageMetadata } from 'astro';
44
import FallbackImage from '../assets/qveris-brand.png';
5-
import { SITE_TITLE } from '../consts';
5+
import { SITE_TITLE, SITE_URL } from '../consts';
66
import { locales, defaultLocale } from '../i18n/config';
77
import type { Locale } from '../i18n/config';
88
import { switchLocaleInPath, getHtmlLang } from '../i18n/utils';
@@ -20,8 +20,6 @@ interface Props {
2020
tags?: string[];
2121
}
2222
23-
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
24-
2523
const {
2624
title,
2725
description,
@@ -34,24 +32,27 @@ const {
3432
tags,
3533
} = Astro.props;
3634
35+
// Use Astro.site with SITE_URL fallback for all URL construction
36+
const site = Astro.site ?? new URL(SITE_URL);
37+
const canonicalURL = new URL(Astro.url.pathname, site);
3738
const ogLocale = lang === 'cn' ? 'zh_CN' : 'en_US';
3839
const imageUrl = new URL(image.src, Astro.url);
3940
4041
// hreflang: dynamically generate alternate links for all supported locales
4142
const pathname = Astro.url.pathname;
4243
const hreflangLinks = locales.map((loc) => ({
4344
hreflang: getHtmlLang(loc),
44-
href: new URL(switchLocaleInPath(pathname, loc), Astro.site),
45+
href: new URL(switchLocaleInPath(pathname, loc), site),
4546
}));
46-
const defaultUrl = new URL(switchLocaleInPath(pathname, defaultLocale), Astro.site);
47+
const defaultUrl = new URL(switchLocaleInPath(pathname, defaultLocale), site);
4748
---
4849

4950
<meta charset="utf-8" />
5051
<meta name="viewport" content="width=device-width,initial-scale=1" />
5152
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
5253
<link rel="icon" href="/favicon.ico" />
5354
<link rel="sitemap" href="/sitemap-index.xml" />
54-
<link rel="alternate" type="application/rss+xml" title={SITE_TITLE} href={new URL('rss.xml', Astro.site)} />
55+
<link rel="alternate" type="application/rss+xml" title={SITE_TITLE} href={new URL('rss.xml', site)} />
5556
<meta name="generator" content={Astro.generator} />
5657

5758
<link rel="preconnect" href="https://fonts.googleapis.com" />

src/components/JsonLd.astro

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ interface CollectionPageData {
4545
4646
type Props =
4747
| { type: 'BlogPosting'; data: BlogPostingData }
48+
| { type: 'TechArticle'; data: BlogPostingData }
4849
| { type: 'BreadcrumbList'; data: BreadcrumbData }
4950
| { type: 'CollectionPage'; data: CollectionPageData };
5051
@@ -53,10 +54,10 @@ import { SITE_URL } from '../consts';
5354
const { type, data } = Astro.props;
5455
const site = Astro.site?.toString().replace(/\/$/, '') ?? SITE_URL;
5556
56-
function buildBlogPosting(d: BlogPostingData) {
57+
function buildArticle(d: BlogPostingData, schemaType: string = 'BlogPosting') {
5758
return {
5859
'@context': 'https://schema.org',
59-
'@type': 'BlogPosting',
60+
'@type': schemaType,
6061
headline: d.title,
6162
description: d.description,
6263
url: d.url,
@@ -128,7 +129,10 @@ let jsonLd: Record<string, unknown>;
128129
129130
switch (type) {
130131
case 'BlogPosting':
131-
jsonLd = buildBlogPosting(data as BlogPostingData);
132+
jsonLd = buildArticle(data as BlogPostingData, 'BlogPosting');
133+
break;
134+
case 'TechArticle':
135+
jsonLd = buildArticle(data as BlogPostingData, 'TechArticle');
132136
break;
133137
case 'BreadcrumbList':
134138
jsonLd = buildBreadcrumbList(data as BreadcrumbData);
@@ -139,4 +143,4 @@ switch (type) {
139143
}
140144
---
141145

142-
<script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
146+
<script type="application/ld+json" set:html={JSON.stringify(jsonLd).replace(/</g, '\\u003c')} />

src/layouts/BlogPost.astro

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type Props = CollectionEntry<'blog'>['data'] & {
2626
blogIndexHref: string;
2727
headings: MarkdownHeading[];
2828
readingTime: number;
29+
wordCount: number;
2930
};
3031
3132
const {
@@ -45,6 +46,7 @@ const {
4546
blogIndexHref,
4647
headings,
4748
readingTime,
49+
wordCount,
4850
} = Astro.props;
4951
5052
const L = t(lang);
@@ -53,8 +55,8 @@ const breadcrumbItems = [
5355
...(category ? [{ label: category, href: blogIndexHref }] : []),
5456
{ label: title },
5557
];
56-
const canonicalUrl = new URL(Astro.url.pathname, Astro.site).toString();
5758
const site = Astro.site?.toString().replace(/\/$/, '') ?? SITE_URL;
59+
const canonicalUrl = new URL(Astro.url.pathname, site).toString();
5860
const breadcrumbLd = breadcrumbItems.map((item) => ({
5961
name: item.label,
6062
...(item.href && { url: `${site}${item.href}` }),
@@ -87,7 +89,7 @@ const breadcrumbLd = breadcrumbItems.map((item) => ({
8789
tags,
8890
category,
8991
lang,
90-
wordCount: lang === 'cn' ? readingTime * 300 : readingTime * 200,
92+
wordCount,
9193
}} />
9294
<JsonLd type="BreadcrumbList" data={{ items: breadcrumbLd }} />
9395

src/layouts/DocPage.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ const breadcrumbItems = [
3838
{ label: L.doc, href: docIndexHref },
3939
{ label: title },
4040
];
41-
const canonicalUrl = new URL(Astro.url.pathname, Astro.site).toString();
4241
const site = Astro.site?.toString().replace(/\/$/, '') ?? SITE_URL;
42+
const canonicalUrl = new URL(Astro.url.pathname, site).toString();
4343
const breadcrumbLd = breadcrumbItems.map((item) => ({
4444
name: item.label,
4545
...(item.href && { url: `${site}${item.href}` }),
@@ -58,7 +58,7 @@ const breadcrumbLd = breadcrumbItems.map((item) => ({
5858
pubDate={pubDate}
5959
updatedDate={updatedDate}
6060
>
61-
<JsonLd type="BlogPosting" data={{
61+
<JsonLd type="TechArticle" data={{
6262
title,
6363
description,
6464
url: canonicalUrl,

src/pages/blog/[lang]/[...slug].astro

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { type CollectionEntry, getCollection, render } from 'astro:content';
33
import BlogPost from '../../../layouts/BlogPost.astro';
44
import { type Locale, isLocale } from '../../../i18n/config';
55
import { localizedPath } from '../../../i18n/utils';
6-
import { getReadingTime } from '../../../utils/reading-time';
6+
import { getReadingStats } from '../../../utils/reading-time';
77
88
export async function getStaticPaths() {
99
const posts = await getCollection('blog');
@@ -57,7 +57,9 @@ const nextPost =
5757
5858
const blogIndexHref = localizedPath(lang, '/blog');
5959
const { Content, headings } = await render(post);
60-
const readingTime = getReadingTime(post.body ?? '');
60+
const stats = getReadingStats(post.body ?? '');
61+
const readingTime = stats.minutes;
62+
const wordCount = stats.wordCount;
6163
---
6264

6365
<BlogPost
@@ -68,6 +70,7 @@ const readingTime = getReadingTime(post.body ?? '');
6870
blogIndexHref={blogIndexHref}
6971
headings={headings}
7072
readingTime={readingTime}
73+
wordCount={wordCount}
7174
>
7275
<Content />
7376
</BlogPost>

src/pages/blog/[lang]/index.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ function postHref(post: (typeof allPosts)[0]) {
3737
}
3838
3939
const site = Astro.site?.toString().replace(/\/$/, '') ?? SITE_URL;
40-
const pageUrl = new URL(Astro.url.pathname, Astro.site).toString();
40+
const pageUrl = new URL(Astro.url.pathname, site).toString();
4141
const allPostRefs = allPosts.map((p) => ({
4242
title: p.data.title,
4343
url: `${site}${postHref(p)}`,

src/utils/reading-time.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
/**
2-
* Estimate reading time from markdown/text content.
2+
* Estimate reading time and word count from markdown/text content.
33
* ~200 words per minute for English, ~300 characters per minute for Chinese.
44
*/
5-
export function getReadingTime(text: string): number {
5+
6+
export interface ReadingStats {
7+
/** Estimated reading time in minutes */
8+
minutes: number;
9+
/** Approximate word/character count (English words + Chinese characters) */
10+
wordCount: number;
11+
}
12+
13+
export function getReadingStats(text: string): ReadingStats {
614
// Strip markdown syntax
715
const stripped = text
816
.replace(/```[\s\S]*?```/g, '') // code blocks
@@ -21,5 +29,14 @@ export function getReadingTime(text: string): number {
2129
const englishWords = withoutChinese.split(/\s+/).filter((w) => w.length > 0).length;
2230

2331
const minutes = englishWords / 200 + chineseChars / 300;
24-
return Math.max(1, Math.ceil(minutes));
32+
33+
return {
34+
minutes: Math.max(1, Math.ceil(minutes)),
35+
wordCount: englishWords + chineseChars,
36+
};
37+
}
38+
39+
/** Convenience wrapper returning only minutes (backward compatible) */
40+
export function getReadingTime(text: string): number {
41+
return getReadingStats(text).minutes;
2542
}

0 commit comments

Comments
 (0)