Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# AsyncTalk - 和我们一起,把 web 开发带向下一个高度
# AsyncTalk - 和我们一起,将 Web 开发带向下一个高度

AsyncTalk 是一档以中文(华语)讨论 Web 开发技术的播客节目。

Expand Down
3 changes: 2 additions & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import react from "@astrojs/react";

// https://astro.build/config
export default defineConfig({
site: "https://AsyncTalk.com",
site: "https://asynctalk.com/",
trailingSlash: "always",
compressHTML: true,
vite: {
plugins: [tailwindcss()],
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check && astro build",
"build": "astro check && astro build && pnpm seo:check",
"generate:thumbnail": "tsx scripts/generate-thumbnail.ts",
"preview": "astro preview",
"astro": "astro"
"astro": "astro",
"seo:check": "node scripts/check-seo.mjs"
},
"dependencies": {
"@astro-community/astro-embed-youtube": "^0.5.10",
Expand Down
4 changes: 0 additions & 4 deletions public/robots.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
User-agent: *
Allow: /
Allow: /posts/
Allow: /rss.xml
Disallow: /og-preview
Disallow: /posts/*-og.png

Sitemap: https://asynctalk.com/sitemap-index.xml
23 changes: 22 additions & 1 deletion public/site.webmanifest
Original file line number Diff line number Diff line change
@@ -1 +1,22 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
{
"name": "AsyncTalk Podcast",
"short_name": "AsyncTalk",
"description": "关注 Web 开发、前端工程化与 AI 的中文播客",
"lang": "zh-CN",
"start_url": "/",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#000000",
"background_color": "#000000",
"display": "standalone"
}
188 changes: 188 additions & 0 deletions scripts/check-seo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";

const projectRoot = path.resolve(import.meta.dirname, "..");
const distDir = path.join(projectRoot, "dist");
const contentDir = path.join(projectRoot, "src/content/posts");
const failures = [];

function expect(condition, message) {
if (!condition) {
failures.push(message);
}
}

function matches(value, pattern) {
return [...value.matchAll(pattern)];
}

function attribute(tag, name) {
return tag.match(new RegExp(`${name}=["']([^"']+)["']`, "i"))?.[1];
}

async function listFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? listFiles(target) : [target];
}),
);
return nested.flat();
}

const contentFiles = (await readdir(contentDir))
.filter((file) => file.endsWith(".mdx"))
.sort();
const publishedEpisodes = [];
const excerpts = [];

for (const file of contentFiles) {
const source = await readFile(path.join(contentDir, file), "utf8");
const status = source.match(/^status:\s*(.+)$/m)?.[1]?.trim();
const excerpt = source
.match(/^excerpt:\s*(.+)$/m)?.[1]
?.trim()
.replace(/^"|"$/g, "");

if (status === "published") {
publishedEpisodes.push(file.replace(/\.mdx$/, ""));
}

expect(Boolean(excerpt), `${file}: missing excerpt`);
if (excerpt) {
const length = [...excerpt].length;
expect(length >= 40 && length <= 160, `${file}: excerpt length is ${length}`);
excerpts.push(excerpt);
}
}

expect(
new Set(excerpts).size === excerpts.length,
"Episode excerpts must be unique",
);

const htmlFiles = (await listFiles(distDir)).filter((file) => file.endsWith(".html"));
expect(
htmlFiles.length === publishedEpisodes.length + 2,
`Expected ${publishedEpisodes.length + 2} public HTML pages, found ${htmlFiles.length}`,
);

for (const file of htmlFiles) {
const html = await readFile(file, "utf8");
const label = path.relative(distDir, file);
const titles = matches(html, /<title>[^<]+<\/title>/gi);
const descriptions = matches(
html,
/<meta\s+name=["']description["'][^>]*>/gi,
);
const canonicals = matches(html, /<link\s+rel=["']canonical["'][^>]*>/gi);
const h1s = matches(html, /<h1(?:\s|>)/gi);
const ogUrls = matches(html, /<meta\s+property=["']og:url["'][^>]*>/gi);
const ogImages = matches(html, /<meta\s+property=["']og:image["'][^>]*>/gi);

expect(/<html\s+lang=["']zh-CN["']/i.test(html), `${label}: incorrect html lang`);
expect(titles.length === 1, `${label}: expected one title, found ${titles.length}`);
expect(
descriptions.length === 1,
`${label}: expected one description, found ${descriptions.length}`,
);
expect(
canonicals.length === 1,
`${label}: expected one canonical, found ${canonicals.length}`,
);
expect(h1s.length === 1, `${label}: expected one h1, found ${h1s.length}`);
expect(ogUrls.length === 1, `${label}: expected one og:url`);
expect(ogImages.length === 1, `${label}: expected one og:image`);
expect(!/Astro description/.test(html), `${label}: placeholder description remains`);
expect(
!/<meta\s+name=["']keywords?["']/i.test(html),
`${label}: obsolete meta keywords remains`,
);

const canonical = canonicals[0] ? attribute(canonicals[0][0], "href") : undefined;
const ogUrl = ogUrls[0] ? attribute(ogUrls[0][0], "content") : undefined;
const ogImage = ogImages[0] ? attribute(ogImages[0][0], "content") : undefined;
expect(
canonical?.startsWith("https://asynctalk.com/") && canonical.endsWith("/"),
`${label}: canonical must be an absolute trailing-slash URL`,
);
expect(ogUrl === canonical, `${label}: og:url must equal canonical`);
expect(
ogImage?.startsWith("https://asynctalk.com/"),
`${label}: og:image must be absolute`,
);

const jsonLdBlocks = matches(
html,
/<script\s+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi,
);
const isHome = label === "index.html";
const isEpisode = label.startsWith(`posts${path.sep}`) && label !== path.join("posts", "index.html");

if (isHome || isEpisode) {
expect(jsonLdBlocks.length === 1, `${label}: expected one JSON-LD block`);
if (jsonLdBlocks[0]) {
try {
const data = JSON.parse(jsonLdBlocks[0][1]);
expect(data["@context"] === "https://schema.org", `${label}: invalid JSON-LD context`);
if (isHome) {
const types = new Set(data["@graph"]?.map((node) => node["@type"]));
expect(types.has("WebSite"), `${label}: missing WebSite data`);
expect(types.has("PodcastSeries"), `${label}: missing PodcastSeries data`);
expect(types.has("Organization"), `${label}: missing Organization data`);
} else {
expect(data["@type"] === "PodcastEpisode", `${label}: missing PodcastEpisode data`);
expect(Boolean(data.description), `${label}: PodcastEpisode description missing`);
expect(Boolean(data.datePublished), `${label}: PodcastEpisode date missing`);
}
} catch (error) {
failures.push(`${label}: invalid JSON-LD (${error.message})`);
}
}
}
}

const sitemap = await readFile(path.join(distDir, "sitemap-0.xml"), "utf8");
const sitemapUrls = matches(sitemap, /<loc>([^<]+)<\/loc>/g).map((match) => match[1]);
expect(
sitemapUrls.length === publishedEpisodes.length + 2,
`Expected ${publishedEpisodes.length + 2} sitemap URLs, found ${sitemapUrls.length}`,
);
expect(
sitemapUrls.every((url) => url.startsWith("https://asynctalk.com/") && url.endsWith("/")),
"Sitemap URLs must be absolute and use trailing slashes",
);
expect(!sitemap.includes("index-legacy"), "Legacy page must not appear in sitemap");
expect(!sitemap.includes("og-preview"), "OG preview must not appear in sitemap");

const rss = await readFile(path.join(distDir, "rss.xml"), "utf8");
const rssItems = matches(rss, /<item>[\s\S]*?<\/item>/g);
expect(
rssItems.length === publishedEpisodes.length,
`Expected ${publishedEpisodes.length} RSS items, found ${rssItems.length}`,
);
for (const [index, item] of rssItems.entries()) {
expect(/<description>/.test(item[0]), `RSS item ${index + 1}: description missing`);
expect(/<guid\s+isPermaLink="true">/.test(item[0]), `RSS item ${index + 1}: guid missing`);
expect(/<author>/.test(item[0]), `RSS item ${index + 1}: author missing`);
}

const robots = await readFile(path.join(distDir, "robots.txt"), "utf8");
expect(!/Disallow:\s*\/posts\/\*-og\.png/.test(robots), "robots.txt blocks OG images");
expect(
robots.includes("Sitemap: https://asynctalk.com/sitemap-index.xml"),
"robots.txt sitemap URL is missing",
);

if (failures.length > 0) {
console.error(`SEO validation failed with ${failures.length} issue(s):`);
for (const failure of failures) {
console.error(`- ${failure}`);
}
process.exitCode = 1;
} else {
console.log(
`SEO validation passed: ${htmlFiles.length} HTML pages, ${sitemapUrls.length} sitemap URLs, ${rssItems.length} RSS items.`,
);
}
29 changes: 18 additions & 11 deletions src/components/OpenGraph/OG.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@ const logo = `data:image/png;base64,${readFileSync(
path.resolve("./src/images/logo.png"),
).toString("base64")}`

const titleFontSize = 3 * ratio
const descriptionFontSize = 1.6 * ratio
const logoSize = 146 * ratio
export default function OG({
title = "AsyncTalk - 和我们一起,将 Web 开发带向下一个高度",
title = "AsyncTalk|和我们一起,将 Web 开发带向下一个高度",
ep,
sp
}: {
Expand All @@ -21,6 +20,8 @@ export default function OG({
heroImageURL?: string
}
) {
const titleFontSize = (title.length > 30 ? 2 : title.length > 20 ? 2.4 : 3) * ratio

return (
<div
style={{
Expand All @@ -47,7 +48,9 @@ export default function OG({
>
<img
src={logo}
width={logoSize * 2}
width={logoSize * 1.7}
height={logoSize * 1.7}
style={{ flexShrink: 0, objectFit: "contain" }}
/>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<h1
Expand All @@ -68,19 +71,23 @@ export default function OG({
>
{title}
</h1>
<p>
<p style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ fontSize: `${descriptionFontSize}rem`, color: primaryColor }}>
Async Talk (asynctalk.com)
</span>
<span style={{ margin: '0 0.5rem', fontSize: `${descriptionFontSize}rem`, color: primaryColor }}>
-
</span>
<span style={{ fontSize: `${descriptionFontSize}rem`, color: primaryColor }}>
{ep ? 'Episode' : 'Special'} {ep ?? sp}
</span>
{(ep !== undefined || sp !== undefined) && (
<>
<span style={{ margin: '0 0.5rem', fontSize: `${descriptionFontSize}rem`, color: primaryColor }}>
-
</span>
<span style={{ fontSize: `${descriptionFontSize}rem`, color: primaryColor }}>
{ep !== undefined ? 'Episode' : 'Special'} {ep ?? sp}
</span>
</>
)}
</p>
</div>
</div>
</div>
);
}
}
2 changes: 1 addition & 1 deletion src/components/Player.astro
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const { link, title } = Astro.props;
立即播放
<IconExternalLink className="ml-1 h-3 w-3" />
</span>
<h1 class="text-xl font-bold">{title}</h1>
<span class="block text-xl font-bold">{title}</span>
</div>
</a>

Expand Down
Loading
Loading