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.` + ); +}