From 6eb8e29990e61c2da0e408f836b2baadeabe6cb3 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Thu, 15 Oct 2026 00:38:05 +0800 Subject: [PATCH] seo: localize blog metadata and FAQ, expand llms.txt, guard blog facts in tests Open Graph locale, JSON-LD language and the blog breadcrumb follow the post's language, the FAQ heading is translated, and time elements use ISO dates. llms.txt and llms-full.txt list core capabilities next to the licensing and deployment boundaries. New tests keep FAQ answers plain text and in step with the English, catch repeated paragraphs, overlong meta descriptions and "prorated" upgrade wording, and a build verifier checks the rendered blog. IndexNow no longer reports success when a sitemap cannot be read. Co-Authored-By: Claude Opus 5.5 --- scripts/indexnow.mjs | 22 +- v3/src/components/blog/BlogFaq.astro | 9 +- v3/src/components/blog/ListItem.astro | 2 +- v3/src/components/blog/SinglePost.astro | 4 +- v3/src/components/common/Metadata.astro | 5 +- v3/src/components/common/StructuredData.astro | 11 +- v3/src/i18n.ts | 24 +- v3/src/i18n/blog.ts | 232 +++++++-- v3/src/layouts/Layout.astro | 4 +- v3/src/locales.ts | 65 ++- v3/src/pages/[...blog]/index.astro | 2 +- v3/src/pages/llms-full.txt.ts | 65 ++- v3/src/pages/llms.txt.ts | 42 +- v3/tests/blog-publication-review.test.mjs | 481 ++++-------------- v3/tests/blog-rendering.test.mjs | 113 ++++ v3/tests/helpers/production-html.mjs | 13 + v3/tests/verify-blog-build.mjs | 151 ++++++ 17 files changed, 733 insertions(+), 512 deletions(-) create mode 100644 v3/tests/blog-rendering.test.mjs create mode 100644 v3/tests/helpers/production-html.mjs create mode 100644 v3/tests/verify-blog-build.mjs diff --git a/scripts/indexnow.mjs b/scripts/indexnow.mjs index de131754..3fd8c358 100644 --- a/scripts/indexnow.mjs +++ b/scripts/indexnow.mjs @@ -47,9 +47,22 @@ if (live?.trim() !== key) { process.exit(1); } +// Sitemaps that could not be read. An error page must not pass for an empty +// sitemap: that would skip its URLs and still report success. +const failed = []; + /** Every under a sitemap, following sitemap indexes. */ async function urlsOf(sitemap) { - const xml = await fetch(sitemap).then((r) => r.text()); + let xml; + try { + const res = await fetch(sitemap); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + xml = await res.text(); + if (!/<(urlset|sitemapindex)[\s>]/.test(xml)) throw new Error("not a sitemap"); + } catch (err) { + failed.push(`${sitemap}: ${err.message}`); + return []; + } const locs = [...xml.matchAll(/\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]); if (!/ u.startsWith(`${ORIGIN}/`)); +for (const f of failed) console.error(`[indexnow] could not read ${f}`); +if (!urlList.length) { + console.error("[indexnow] no URLs found, nothing submitted"); + process.exit(1); +} // One request takes up to 10,000 URLs. for (let i = 0; i < urlList.length; i += 10000) { @@ -75,3 +93,5 @@ for (let i = 0; i < urlList.length; i += 10000) { console.log(`[indexnow] ${batch.length} URLs (${which}) submitted: HTTP ${res.status}`); if (!res.ok) process.exit(1); } +// The readable sitemaps went in above; an unreadable one still fails the run. +if (failed.length) process.exit(1); diff --git a/v3/src/components/blog/BlogFaq.astro b/v3/src/components/blog/BlogFaq.astro index e7cdda05..df133eaa 100644 --- a/v3/src/components/blog/BlogFaq.astro +++ b/v3/src/components/blog/BlogFaq.astro @@ -1,15 +1,20 @@ --- +import type { Lang } from '@/i18n'; +import { getBlogCopy } from '@/i18n/blog'; + export interface Props { items?: Array<{ question: string; answer: string }>; + lang?: Lang; } -const { items = [] } = Astro.props; +const { items = [], lang } = Astro.props; +const copy = getBlogCopy(lang); --- { items.length > 0 && (
-

Frequently asked questions

+

{copy.frequentlyAskedQuestions}

{items.map((item) => (
diff --git a/v3/src/components/blog/ListItem.astro b/v3/src/components/blog/ListItem.astro index d5af4c15..062508c1 100644 --- a/v3/src/components/blog/ListItem.astro +++ b/v3/src/components/blog/ListItem.astro @@ -65,7 +65,7 @@ const link = APP_BLOG?.post?.isEnabled ? getPermalink(post.permalink, 'post') :
- { diff --git a/v3/src/components/blog/SinglePost.astro b/v3/src/components/blog/SinglePost.astro index affa6e22..44ecb4e6 100644 --- a/v3/src/components/blog/SinglePost.astro +++ b/v3/src/components/blog/SinglePost.astro @@ -27,7 +27,7 @@ const copy = getBlogCopy(post.lang);

- { @@ -37,7 +37,7 @@ const copy = getBlogCopy(post.lang); · {copy.updated}:{' '} - + ) diff --git a/v3/src/components/common/Metadata.astro b/v3/src/components/common/Metadata.astro index 4c6dc938..34a111f1 100644 --- a/v3/src/components/common/Metadata.astro +++ b/v3/src/components/common/Metadata.astro @@ -9,9 +9,11 @@ import type { MetaData } from '~/types'; import { getCanonical } from '~/utils/permalinks'; import { adaptOpenGraphImages } from '~/utils/images'; +import { LOCALES, type Lang } from '@/i18n'; export interface Props extends MetaData { dontUseTitleTemplate?: boolean; + lang?: Lang; } const { @@ -24,6 +26,7 @@ const { author, openGraph = {}, twitter = {}, + lang = Astro.currentLocale as Lang, } = Astro.props; const seoProps: AstroSeoProps = merge( @@ -38,7 +41,7 @@ const seoProps: AstroSeoProps = merge( url: canonical, site_name: SITE?.name, images: [], - locale: I18N?.language || 'en', + locale: LOCALES[lang || I18N?.language]?.openGraphLocale || 'en_US', type: 'website', }, twitter: { diff --git a/v3/src/components/common/StructuredData.astro b/v3/src/components/common/StructuredData.astro index ba46c8f2..99fddf96 100644 --- a/v3/src/components/common/StructuredData.astro +++ b/v3/src/components/common/StructuredData.astro @@ -2,6 +2,7 @@ import appleTouchIcon from '~/assets/favicons/apple-touch-icon.png'; import type { MetaData } from '~/types'; +import type { Lang } from '@/i18n'; import { getKeywordList, getLocaleLanguageTag, @@ -17,6 +18,7 @@ import { export interface Props { type?: 'website' | 'article' | 'software' | 'faq'; metadata?: MetaData; + lang?: Lang; article?: { title: string; description?: string; @@ -29,11 +31,11 @@ export interface Props { faqItems?: Array<{ question: string; answer: string }>; } -const { type = 'website', metadata, article, faqItems = [] } = Astro.props; +const { type = 'website', metadata, lang, article, faqItems = [] } = Astro.props; const siteOrigin = getSiteOrigin(Astro.site); const currentUrl = metadata?.canonical || new URL(Astro.url.pathname, siteOrigin).toString(); -const localeTag = getLocaleLanguageTag(Astro.currentLocale); +const localeTag = getLocaleLanguageTag(lang || Astro.currentLocale); const pageKind = getPageKind(Astro.url.pathname); const pageTitle = getPageTitle(metadata); const pageName = getPageName(Astro.url.pathname, metadata); @@ -220,7 +222,7 @@ const breadcrumbItems = (() => { '@type': 'ListItem', position: 2, name: 'Blog', - item: `${siteOrigin}/blog`, + item: `${siteOrigin}${localePrefix}/blog`, }); items.push({ '@type': 'ListItem', @@ -291,4 +293,5 @@ const jsonLd = { }; --- -

example
text.'; + const html = await render('components/common/StructuredData.astro', { + ...articleProps, + faqItems: [{ question: 'What is a literal example?', answer }], + }); + const $ = load(await minify(html, { ...compressionDefaults, removeAttributeQuotes: false })); + const graph = JSON.parse($('script[type="application/ld+json"]').html())['@graph']; + assert.equal(graph.find((item) => item['@type'] === 'FAQPage').mainEntity[0].acceptedAnswer.text, answer); + assert.equal($('#injected').length, 0); +}); + +test('an explicitly translated page labels its article and FAQ in that language', async () => { + const html = await render('components/common/StructuredData.astro', { + ...articleProps, + faqItems: [{ question: '问题', answer: '回答' }], + }); + const graph = JSON.parse(load(html)('script[type="application/ld+json"]').html())['@graph']; + for (const type of ['WebPage', 'BlogPosting', 'FAQPage']) { + assert.equal(graph.find((item) => item['@type'] === type).inLanguage, 'zh-CN', type); + } +}); + +test('a translated article breadcrumb links to its own language blog', async () => { + const html = await render('components/common/StructuredData.astro', articleProps); + const graph = JSON.parse(load(html)('script[type="application/ld+json"]').html())['@graph']; + const items = graph.find((item) => item['@type'] === 'BreadcrumbList').itemListElement; + assert.equal(items[1].item, 'https://rustdesk.com/zh-cn/blog'); +}); + +const post = { + id: 'zh-cn/example.md', + lang: 'zh-cn', + translationKey: 'example', + slug: 'example', + permalink: 'zh-cn/blog/example', + title: '示例文章', + publishDate: new Date('2026-07-09T10:47:00Z'), + updateDate: new Date('2026-10-14T00:00:00Z'), + tags: [], +}; + +test('article publication and update times expose ISO timestamps', async () => { + const $ = load( + await render('components/blog/SinglePost.astro', { post, url: 'https://rustdesk.com/zh-cn/blog/example' }) + ); + assert.deepEqual( + $('time') + .map((_, item) => $(item).attr('datetime')) + .get(), + ['2026-07-09T10:47:00.000Z', '2026-10-14T00:00:00.000Z'] + ); +}); + +test('blog list cards expose the publication time as an ISO timestamp', async () => { + const $ = load(await render('components/blog/ListItem.astro', { post })); + assert.equal($('time').attr('datetime'), '2026-07-09T10:47:00.000Z'); +}); diff --git a/v3/tests/helpers/production-html.mjs b/v3/tests/helpers/production-html.mjs new file mode 100644 index 00000000..268b1b5e --- /dev/null +++ b/v3/tests/helpers/production-html.mjs @@ -0,0 +1,13 @@ +import { createRequire } from 'node:module'; + +// Resolve each transitive dependency from the production package that owns it, +// so tests do not depend on npm hoisting or add another copy of the compressor. +const compressor = createRequire(import.meta.resolve('astro-compress')); +export const { minify } = compressor('html-minifier-terser'); +export const { default: compressionDefaults } = await import( + new URL('../Variable/HTML/html-minifier-terser.js', import.meta.resolve('astro-compress')) +); + +const icons = createRequire(import.meta.resolve('astro-icon')); +const iconify = createRequire(icons.resolve('@iconify/tools')); +export const { load } = iconify('cheerio'); diff --git a/v3/tests/verify-blog-build.mjs b/v3/tests/verify-blog-build.mjs new file mode 100644 index 00000000..399ece61 --- /dev/null +++ b/v3/tests/verify-blog-build.mjs @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { readFile, readdir } from 'node:fs/promises'; +import { basename, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'js-yaml'; +import slugify from 'limax'; +import { load } from './helpers/production-html.mjs'; + +async function* htmlFiles(directory) { + for (const item of await readdir(directory, { withFileTypes: true })) { + const path = resolve(directory, item.name); + if (item.isDirectory()) yield* htmlFiles(path); + else if (item.name.endsWith('.html')) yield path; + } +} + +const normalizedText = (text) => text.replace(/\s+/g, ' ').trim(); +const withoutTrailingSlash = (path) => path.replace(/\/$/, '') || '/'; + +async function publishedPostPaths() { + const paths = new Set(); + const root = fileURLToPath(new URL('../src/content/post/', import.meta.url)); + for (const language of await readdir(root)) { + for (const name of await readdir(resolve(root, language))) { + if (!/\.mdx?$/.test(name)) continue; + const source = await readFile(resolve(root, language, name), 'utf8'); + const data = yaml.load(source.match(/^---\r?\n([\s\S]*?)\r?\n---/)[1]); + if (data.draft) continue; + const slug = slugify( + String(data.slug || basename(name).replace(/\.mdx?$/, '')) + .split('/') + .pop() + ); + paths.add(`${data.lang === 'en' ? '' : `/${data.lang}`}/blog/${slug}`); + } + } + return paths; +} + +// Run after `npm run build`: node tests/verify-blog-build.mjs [dist-directory] +// This checks the actual compressed output, including Layout -> metadata propagation. +export async function verifyBlogBuild(directory = fileURLToPath(new URL('../dist/', import.meta.url))) { + const pages = new Map(); + for await (const file of htmlFiles(directory)) { + const path = `/${file + .slice(resolve(directory).length + 1) + .replace(/index\.html$/, '') + .replace(/\/$/, '')}`; + pages.set(path, { file, html: await readFile(file, 'utf8') }); + } + + let posts = 0; + let faqs = 0; + const languages = new Set(); + const failures = []; + const expectedPaths = await publishedPostPaths(); + const observedPaths = new Set(); + for (const [path, { file, html }] of pages) { + if (!/^\/(?:[a-z-]+\/)?blog\/[^/]+$/.test(path) || /\/\d+$/.test(path)) continue; + observedPaths.add(path); + const $ = load(html); + try { + const scripts = $('script[type="application/ld+json"]').toArray(); + assert.ok(scripts.length > 0, 'article must expose JSON-LD'); + const graph = scripts.flatMap((script) => JSON.parse($(script).html())['@graph'] || []); + const article = graph.find((item) => item['@type'] === 'BlogPosting'); + assert.ok(article, 'article must expose BlogPosting'); + posts += 1; + const language = $('html').attr('lang'); + languages.add(language); + for (const item of graph.filter((item) => ['WebPage', 'BlogPosting', 'FAQPage'].includes(item['@type']))) { + assert.equal(item.inLanguage, language, `${item['@type']} language must match the page`); + } + + const openGraphLocale = $('meta[property="og:locale"]').attr('content'); + const [languageCode, territory] = language.split('-'); + assert.match( + openGraphLocale || '', + new RegExp(`^${languageCode}_[A-Z]{2}$`), + 'Open Graph needs the page language and territory' + ); + if (territory) assert.equal(openGraphLocale, `${languageCode}_${territory}`); + + const canonical = $('link[rel="canonical"]').attr('href'); + assert.equal(withoutTrailingSlash(new URL(canonical).pathname), path, 'article canonical must point to itself'); + assert.equal(article.url, canonical, 'BlogPosting and HTML must share the canonical URL'); + for (const alternate of $('link[rel="alternate"][hreflang]').toArray()) { + const alternatePath = withoutTrailingSlash(new URL($(alternate).attr('href')).pathname); + assert.ok(pages.has(alternatePath), `alternate must exist: ${alternatePath}`); + } + + const localePrefix = languageCode === 'en' ? '' : `/${language.toLowerCase()}`; + const breadcrumb = graph.find((item) => item['@type'] === 'BreadcrumbList'); + assert.equal( + breadcrumb?.itemListElement[1]?.item, + `https://rustdesk.com${localePrefix}/blog`, + 'breadcrumb must keep the article language' + ); + for (const time of $('time').toArray()) { + const value = $(time).attr('datetime'); + assert.match(value || '', /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, 'time datetime must use ISO format'); + assert.ok(Number.isFinite(Date.parse(value)), 'time datetime must represent a real date'); + } + + const faq = graph.find((item) => item['@type'] === 'FAQPage'); + const visibleFaq = $('#post-faq-heading').closest('section').find('details').toArray(); + if (faq) { + faqs += 1; + const heading = normalizedText($('#post-faq-heading').text()); + assert.ok(heading, 'visible FAQ needs a heading'); + if (language !== 'en') assert.notEqual(heading, 'Frequently asked questions', 'FAQ heading must be localized'); + assert.equal(visibleFaq.length, faq.mainEntity.length, 'every FAQ answer must also be visible'); + faq.mainEntity.forEach((item, index) => { + assert.equal( + normalizedText($(visibleFaq[index]).find('summary').text()), + normalizedText(item.name), + 'FAQ question must match visible text' + ); + assert.equal( + normalizedText($(visibleFaq[index]).find('p').text()), + normalizedText(item.acceptedAnswer.text), + 'FAQ answer must match visible text' + ); + }); + } else { + assert.equal(visibleFaq.length, 0, 'visible FAQ must expose matching structured data'); + } + } catch (error) { + failures.push(`${file}: ${error.message}`); + } + } + assert.ok(posts > 0, 'build must contain blog articles'); + assert.deepEqual( + [...observedPaths].sort(), + [...expectedPaths].sort(), + 'build must contain exactly the published source articles' + ); + assert.equal( + failures.length, + 0, + `Built blog validation failed (${failures.length} pages):\n${failures.slice(0, 12).join('\n')}` + ); + return { posts, faqs, languages: [...languages].sort() }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const result = await verifyBlogBuild(process.argv[2]); + console.log( + `Validated ${result.posts} blog articles, ${result.faqs} visible FAQ blocks, ${result.languages.length} languages.` + ); +}