diff --git a/examples/blog-bilingual-index-smoke.mjs b/examples/blog-bilingual-index-smoke.mjs index 8fb14b4883..92b7cc2e07 100644 --- a/examples/blog-bilingual-index-smoke.mjs +++ b/examples/blog-bilingual-index-smoke.mjs @@ -2,10 +2,11 @@ // Fast source-level contract test for the bilingual static Blog catalog. import { existsSync } from "node:fs"; -import { readFile, readdir } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { cp, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { deepStrictEqual } from "node:assert/strict"; +import { deepStrictEqual, rejects } from "node:assert/strict"; +import { tmpdir } from "node:os"; import { comparePublicationDates, sortBlogIndex, @@ -28,6 +29,30 @@ function assertIncludes(html, value, message) { if (!html.includes(value)) throw new Error(message); } +function hasAnchorToHref(html, href, sourceHref) { + const expectedUrl = new URL(href, sourceHref).href; + const anchors = html.matchAll(/])((?:[^>"']|"[^"]*"|'[^']*')*)>/gi); + for (const [, attributes] of anchors) { + const parsedAttributes = attributes.matchAll( + /\s+([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g, + ); + let linkHref = null; + for (const [, name, doubleQuoted, singleQuoted, unquoted] of parsedAttributes) { + if (name.toLowerCase() === "href") { + linkHref = doubleQuoted ?? singleQuoted ?? unquoted ?? null; + break; + } + } + if (linkHref === null) continue; + try { + if (new URL(linkHref, sourceHref).href === expectedUrl) return true; + } catch { + // Invalid URLs cannot satisfy the paired-edition navigation requirement. + } + } + return false; +} + function sectionIds(html) { return [...html.matchAll(/ match[1]); } @@ -99,7 +124,9 @@ export async function validateBilingualBlog(blogDir) { assertIncludes(articleHtml, ``, `Blog article language drifted: ${slug}`); assertIncludes(articleHtml, "

", `Blog article must contain a visible title: ${slug}`); assertIncludes(articleHtml, `rel="canonical" href="${locale.canonicalHref(slug)}"`, `Blog canonical URL drifted: ${slug}`); - assertIncludes(articleHtml, `href="${locale.counterpartHref(slug)}"`, `Blog article must link its paired edition: ${slug}`); + if (!hasAnchorToHref(articleHtml, locale.counterpartHref(slug), locale.canonicalHref(slug))) { + throw new Error(`Blog article must link its paired edition: ${slug}`); + } for (const hreflang of ["en", "zh-CN", "x-default"]) { assertIncludes(articleHtml, `hreflang="${hreflang}"`, `Blog article is missing ${hreflang}: ${slug}`); } @@ -171,12 +198,46 @@ function fakeBlogIndex(order, { focusedSlug = null } = {}) { return index; } +async function assertPairedLinkRejected(blogDir, slug, anchor, message) { + const temporaryRoot = await mkdtemp(join(tmpdir(), "loopx-blog-paired-link-")); + const fixtureBlog = join(temporaryRoot, "blog"); + try { + await cp(blogDir, fixtureBlog, { recursive: true }); + const articlePath = resolve(fixtureBlog, slug, "index.html"); + const html = await readFile(articlePath, "utf8"); + const pairedAnchor = ``; + if (!html.includes(pairedAnchor)) { + throw new Error(`Could not locate the paired anchor in ${slug}`); + } + await writeFile(articlePath, html.replace(pairedAnchor, anchor)); + await rejects( + () => validateBilingualBlog(fixtureBlog), + /Blog article must link its paired edition/, + message, + ); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +} + const modulePath = fileURLToPath(import.meta.url); if (process.argv[1] && resolve(process.argv[1]) === modulePath) { // Independent expectations for unsorted input, partial dates, ties and undated posts. const dates = ["2026-09", "", "2026-09-15", "2026-10-02", "2026-09-26"]; deepStrictEqual(dates.sort(comparePublicationDates), ["2026-10-02", "2026-09-26", "2026-09-15", "2026-09", ""]); deepStrictEqual(comparePublicationDates("2026-10-02", "2026-10-02"), 0); + const englishArticle = "https://loopx-project.github.io/loopx/blog/example/"; + const relativeChineseArticle = '中文'; + deepStrictEqual( + hasAnchorToHref(relativeChineseArticle, "../../blog/zh/example/", englishArticle), + true, + "Equivalent relative paths must resolve to the paired edition", + ); + deepStrictEqual( + hasAnchorToHref('中文', "../../blog/zh/example/", englishArticle), + false, + "A link to a different article must not satisfy the paired-edition check", + ); // A module response that arrives after keyboard navigation started must not // move an already ordered catalog or drop the focused article link. const ordered = fakeBlogIndex( @@ -204,5 +265,18 @@ if (process.argv[1] && resolve(process.argv[1]) === modulePath) { const repoRoot = resolve(dirname(modulePath), ".."); const blogDir = resolve(repoRoot, "apps/presentation/site/public/blog"); const { articleSlugs } = await validateBilingualBlog(blogDir); + const pairedSlug = "edgebench-feedback-and-memory"; + await assertPairedLinkRejected( + blogDir, + pairedSlug, + `中文`, + "A data-href attribute must not substitute for a navigable paired link", + ); + await assertPairedLinkRejected( + blogDir, + pairedSlug, + `中文`, + "A correct data-href must not hide an incorrect navigable href", + ); console.log(`blog-bilingual-index-smoke: ok (${articleSlugs.length} paired articles)`); }