mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(landing): link English-only tool-detail and self-hosted pages to un-prefixed URLs (#553)
Tool-detail pages (/tools/<section>/<tool>/) and the /self-hosted pages are built only in English, with no per-locale route, so a locale-prefixed link 404s in the static build. Add an enOnlyHref() helper and use it for those links in Footer, Navbar, HeroSearch, and ToolGrid so localized pages point at the English pages that actually exist. Adds an e2e guard asserting localized pages emit un-prefixed URLs for those routes.
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||||
import LanguageSwitcher from "@/components/LanguageSwitcher.astro";
|
import LanguageSwitcher from "@/components/LanguageSwitcher.astro";
|
||||||
import { t } from "@/i18n";
|
import { t } from "@/i18n";
|
||||||
import { localizeHref } from "@/lib/i18n-page";
|
import { enOnlyHref, localizeHref } from "@/lib/i18n-page";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
locale?: string;
|
locale?: string;
|
||||||
@@ -29,7 +29,7 @@ const columns = [
|
|||||||
title: t(locale, "footer.col.solutions"),
|
title: t(locale, "footer.col.solutions"),
|
||||||
links: [
|
links: [
|
||||||
{ label: t(locale, "footer.link.enterprise"), href: localizeHref(locale, "/#enterprise") },
|
{ label: t(locale, "footer.link.enterprise"), href: localizeHref(locale, "/#enterprise") },
|
||||||
{ label: t(locale, "footer.link.selfHosted"), href: localizeHref(locale, "/self-hosted") },
|
{ label: t(locale, "footer.link.selfHosted"), href: enOnlyHref("/self-hosted") },
|
||||||
{ label: t(locale, "footer.link.openSource"), href: localizeHref(locale, "/#open-source") },
|
{ label: t(locale, "footer.link.openSource"), href: localizeHref(locale, "/#open-source") },
|
||||||
{ label: t(locale, "footer.link.alternatives"), href: localizeHref(locale, "/alternatives") },
|
{ label: t(locale, "footer.link.alternatives"), href: localizeHref(locale, "/alternatives") },
|
||||||
{ label: t(locale, "footer.link.pricing"), href: localizeHref(locale, "/#pricing") },
|
{ label: t(locale, "footer.link.pricing"), href: localizeHref(locale, "/#pricing") },
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { TOOLS, toolSection } from "@snapotter/shared";
|
import { TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import * as lucideIcons from "lucide";
|
import * as lucideIcons from "lucide";
|
||||||
import { t } from "@/i18n";
|
import { t } from "@/i18n";
|
||||||
import { localizeHref } from "@/lib/i18n-page";
|
import { enOnlyHref, localizeHref } from "@/lib/i18n-page";
|
||||||
import { loadToolStrings } from "@/lib/tool-strings";
|
import { loadToolStrings } from "@/lib/tool-strings";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -72,7 +72,7 @@ function renderIcon(iconName: string): string {
|
|||||||
hidden
|
hidden
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href={localizeHref(locale, `/tools/${toolSection(tool)}/${tool.id}/`)}
|
href={enOnlyHref(`/tools/${toolSection(tool)}/${tool.id}/`)}
|
||||||
class="flex items-center gap-3 px-4 py-2.5 no-underline transition-colors hover:bg-primary-subtle"
|
class="flex items-center gap-3 px-4 py-2.5 no-underline transition-colors hover:bg-primary-subtle"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
// biome-ignore-all lint/correctness/noUnusedVariables: Astro template consumes frontmatter values.
|
||||||
import { t } from "@/i18n";
|
import { t } from "@/i18n";
|
||||||
import { localizeHref } from "@/lib/i18n-page";
|
import { enOnlyHref, localizeHref } from "@/lib/i18n-page";
|
||||||
import { formatCompact, getStarCount } from "@/lib/stats";
|
import { formatCompact, getStarCount } from "@/lib/stats";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -16,12 +16,12 @@ type NavItem = { label: string; href: string; external?: boolean; desc?: string
|
|||||||
const productLinks: NavItem[] = [
|
const productLinks: NavItem[] = [
|
||||||
{
|
{
|
||||||
label: t(locale, "nav.product.developers.label"),
|
label: t(locale, "nav.product.developers.label"),
|
||||||
href: localizeHref(locale, "/self-hosted/file-conversion-api"),
|
href: enOnlyHref("/self-hosted/file-conversion-api"),
|
||||||
desc: t(locale, "nav.product.developers.desc"),
|
desc: t(locale, "nav.product.developers.desc"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t(locale, "nav.product.selfHosted.label"),
|
label: t(locale, "nav.product.selfHosted.label"),
|
||||||
href: localizeHref(locale, "/self-hosted"),
|
href: enOnlyHref("/self-hosted"),
|
||||||
desc: t(locale, "nav.product.selfHosted.desc"),
|
desc: t(locale, "nav.product.selfHosted.desc"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { CATEGORIES, TOOLS, toolSection } from "@snapotter/shared";
|
import { CATEGORIES, TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import * as lucideIcons from "lucide";
|
import * as lucideIcons from "lucide";
|
||||||
import { t } from "@/i18n";
|
import { t } from "@/i18n";
|
||||||
import { localizeHref } from "@/lib/i18n-page";
|
import { enOnlyHref, localizeHref } from "@/lib/i18n-page";
|
||||||
import { loadToolStrings } from "@/lib/tool-strings";
|
import { loadToolStrings } from "@/lib/tool-strings";
|
||||||
import type { ModalityFilter, ToolSearchItem } from "../lib/tool-search";
|
import type { ModalityFilter, ToolSearchItem } from "../lib/tool-search";
|
||||||
import SectionHeading from "./SectionHeading.astro";
|
import SectionHeading from "./SectionHeading.astro";
|
||||||
@@ -169,7 +169,7 @@ function toToolSearchItem(tool: (typeof TOOLS)[number]): ToolSearchItem {
|
|||||||
description: toolStrings[tool.id]?.description ?? tool.description,
|
description: toolStrings[tool.id]?.description ?? tool.description,
|
||||||
modality: tool.modality as ToolModality,
|
modality: tool.modality as ToolModality,
|
||||||
category: tool.category,
|
category: tool.category,
|
||||||
url: localizeHref(locale, `/tools/${toolSection(tool)}/${tool.id}/`),
|
url: enOnlyHref(`/tools/${toolSection(tool)}/${tool.id}/`),
|
||||||
icon: tool.icon,
|
icon: tool.icon,
|
||||||
iconSvg: renderIcon(tool.icon),
|
iconSvg: renderIcon(tool.icon),
|
||||||
color: category?.color ?? "#E07832",
|
color: category?.color ?? "#E07832",
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export function localizeHref(locale: string, path: string): string {
|
|||||||
return `${getRelativeLocaleUrl(locale, clean)}${hash}`;
|
return `${getRelativeLocaleUrl(locale, clean)}${hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Href for a page that is built ONLY in English: the individual tool-detail pages
|
||||||
|
* under /tools/<section>/<tool> and the /self-hosted pages. These have no per-locale
|
||||||
|
* route, so a locale-prefixed URL would 404. Always emit the un-prefixed English URL
|
||||||
|
* so localized pages link to the page that actually exists.
|
||||||
|
*/
|
||||||
|
export function enOnlyHref(path: string): string {
|
||||||
|
return localizeHref("en", path);
|
||||||
|
}
|
||||||
|
|
||||||
/** Direction attribute for the current locale. */
|
/** Direction attribute for the current locale. */
|
||||||
export function dirFor(locale: string): "ltr" | "rtl" {
|
export function dirFor(locale: string): "ltr" | "rtl" {
|
||||||
return isRtl(locale) ? "rtl" : "ltr";
|
return isRtl(locale) ? "rtl" : "ltr";
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// tests/e2e-landing/en-only-links.spec.ts
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
// Regression guard (QA sweep): tool-detail pages (/tools/<section>/<tool>/) and the
|
||||||
|
// /self-hosted pages are built ONLY in English (no per-locale route). Localized pages
|
||||||
|
// must therefore link to their UN-PREFIXED English URLs; a locale-prefixed link 404s in
|
||||||
|
// the static build. We assert on the emitted href attributes (identical in dev and the
|
||||||
|
// static build) so this catches the regression even though the webServer runs `astro dev`.
|
||||||
|
const LOCALES = ["de", "ja"];
|
||||||
|
|
||||||
|
async function collectHrefs(page: import("@playwright/test").Page): Promise<string[]> {
|
||||||
|
const anchors = await page.$$("a[href]");
|
||||||
|
const hrefs: string[] = [];
|
||||||
|
for (const a of anchors) hrefs.push((await a.getAttribute("href")) ?? "");
|
||||||
|
return hrefs;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const loc of LOCALES) {
|
||||||
|
test(`${loc}: English-only tool-detail & self-hosted links are not locale-prefixed`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const res = await page.goto(`/${loc}/`);
|
||||||
|
expect(res?.status()).toBeLessThan(400);
|
||||||
|
|
||||||
|
const hrefs = await collectHrefs(page);
|
||||||
|
|
||||||
|
// Tool-detail = /<loc>/tools/<section>/<tool>/ (two path segments after /tools/).
|
||||||
|
// The /<loc>/tools/ index and /<loc>/tools/<section>/ pages ARE localized and stay prefixed.
|
||||||
|
const toolDetailRe = new RegExp(`^/${loc}/tools/[^/]+/[^/]+/?$`);
|
||||||
|
const badToolDetail = hrefs.filter((h) => toolDetailRe.test(h));
|
||||||
|
expect(
|
||||||
|
badToolDetail,
|
||||||
|
`localized tool-detail links on /${loc}/ (must be un-prefixed /tools/...): ${badToolDetail.slice(0, 3).join(", ")}`,
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
// /self-hosted pages are English-only.
|
||||||
|
const badSelfHosted = hrefs.filter((h) => h.startsWith(`/${loc}/self-hosted`));
|
||||||
|
expect(
|
||||||
|
badSelfHosted,
|
||||||
|
`localized self-hosted links on /${loc}/: ${badSelfHosted.join(", ")}`,
|
||||||
|
).toEqual([]);
|
||||||
|
|
||||||
|
// Sanity: the un-prefixed English tool-detail links are actually present (fix didn't drop them).
|
||||||
|
const enToolDetail = hrefs.filter((h) => /^\/tools\/[^/]+\/[^/]+\/?$/.test(h));
|
||||||
|
expect(enToolDetail.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user