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
19 changes: 19 additions & 0 deletions .github/workflows/seo-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: SEO regressions

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
seo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: node --test scripts/seo.test.mjs
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,22 @@ The source code for https://loro.dev/
pnpm install
pnpm dev
```

## SEO checks and translations

- `pnpm test:seo` runs fast, dependency-free regression checks.
- `pnpm build` builds the site and sitemap and checks generated metadata before
Cloudflare packaging. `pnpm test:seo:build` reruns those checks against the
current build: canonical/hreflang tags, language navigation, article headings,
and API components staying out of public routes.
- Chinese content lives in [loro-dev/loro-docs-zh](https://github.com/loro-dev/loro-docs-zh).
`lib/chinese-routes.mjs` records the source commit and routes that can be
advertised as translations. Untranslated pages do not emit a Chinese
alternate, and their Chinese language-menu entry is disabled with an
explanation. English canonical URLs always use `https://loro.dev`;
Chinese pages use their own canonical URLs in the separate Chinese repo.
- After publishing new Chinese translations, check their public URLs and run
`node scripts/update-chinese-routes.mjs ../loro-docs-zh` against that checkout.
Review the route-list diff, then run both SEO checks above. The generator
reads local Markdown/MDX route files; it makes no network calls at build time.
Unknown new English pages are excluded until their translation is published.
16 changes: 11 additions & 5 deletions components/LanguageDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { useCallback, useEffect, useRef, useState } from "react";
import type { SVGProps } from "react";
import { useRouter } from "next/router";
import { hasChineseTranslation } from "../lib/seo.mjs";

import { cn } from "@/lib/utils/cn";

Expand Down Expand Up @@ -57,6 +59,8 @@ function buildTargetUrl(domain: string) {
}

export default function LanguageDropdown() {
const { asPath } = useRouter();
const chineseAvailable = hasChineseTranslation(asPath);
const [isOpen, setIsOpen] = useState(false);
const [activeLanguage, setActiveLanguage] = useState<LanguageId>("en");
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -90,7 +94,7 @@ export default function LanguageDropdown() {

const handleSelect = useCallback((language: LanguageId) => {
const target = LANGUAGES.find((item) => item.id === language);
if (!target) {
if (!target || (language === "zh" && !chineseAvailable)) {
return;
}

Expand All @@ -100,7 +104,7 @@ export default function LanguageDropdown() {
if (typeof window !== "undefined") {
window.location.href = buildTargetUrl(target.domain);
}
}, []);
}, [chineseAvailable]);

return (
<div
Expand All @@ -121,23 +125,25 @@ export default function LanguageDropdown() {
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(
"absolute right-0 mt-2 w-32 overflow-hidden rounded-md border border-gray-200 bg-white py-1 shadow-lg transition-opacity duration-150 dark:border-neutral-700 dark:bg-neutral-900",
"absolute right-0 mt-2 w-44 overflow-hidden rounded-md border border-gray-200 bg-white py-1 shadow-lg transition-opacity duration-150 dark:border-neutral-700 dark:bg-neutral-900",
isOpen ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
)}
>
{LANGUAGES.map(({ id, label }) => (
<button
key={id}
type="button"
disabled={id === "zh" && !chineseAvailable}
title={id === "zh" && !chineseAvailable ? "Chinese translation is not available for this page" : undefined}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-sm text-left transition-colors",
"flex w-full items-center gap-2 px-3 py-2 text-sm text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
id === activeLanguage
? "bg-gray-100 font-medium text-gray-900 dark:bg-neutral-800 dark:text-gray-100"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-neutral-800 dark:hover:text-gray-100"
)}
onClick={() => handleSelect(id)}
>
{label}
{label}{id === "zh" && !chineseAvailable ? "(暂无译文)" : ""}
</button>
))}
</div>
Expand Down
File renamed without changes.
File renamed without changes.
74 changes: 74 additions & 0 deletions lib/chinese-routes.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Chinese pages are maintained in a separate repository. Only advertise known translations.
// Refresh with scripts/update-chinese-routes.mjs after those pages are published.
export default {
"repository": "https://github.com/loro-dev/loro-docs-zh",
"commit": "24c9bc9ce66e358029787f49358eebb27e3fd328",
"routes": [
"/",
"/about",
"/blog",
"/blog/crdt-richtext",
"/blog/loro-mirror",
"/blog/loro-now-open-source",
"/blog/loro-protocol",
"/blog/loro-richtext",
"/blog/movable-tree",
"/blog/v1.0",
"/changelog",
"/changelog/inspector-v0.1.0",
"/changelog/v1.0.0-beta",
"/changelog/v1.1.0",
"/changelog/v1.2.0",
"/changelog/v1.3.0",
"/changelog/v1.4.0",
"/changelog/v1.4.7",
"/changelog/v1.5.0",
"/changelog/v1.6.0",
"/changelog/v1.8.0",
"/docs",
"/docs/advanced/cid",
"/docs/advanced/import_batch",
"/docs/advanced/inspector",
"/docs/advanced/timestamp",
"/docs/advanced/undo",
"/docs/advanced/version_deep_dive",
"/docs/api/js",
"/docs/concepts/attached_detached",
"/docs/concepts/choose_crdt_type",
"/docs/concepts/container",
"/docs/concepts/crdt",
"/docs/concepts/cursor_stable_positions",
"/docs/concepts/event_graph_walker",
"/docs/concepts/frontiers",
"/docs/concepts/import_status",
"/docs/concepts/operations_changes",
"/docs/concepts/oplog_docstate",
"/docs/concepts/peerid_management",
"/docs/concepts/shallow_snapshots",
"/docs/concepts/transaction_model",
"/docs/concepts/version_vector",
"/docs/concepts/when_not_crdt",
"/docs/examples",
"/docs/llm",
"/docs/performance",
"/docs/performance/docsize",
"/docs/performance/native",
"/docs/tutorial/composition",
"/docs/tutorial/counter",
"/docs/tutorial/cursor",
"/docs/tutorial/encoding",
"/docs/tutorial/ephemeral",
"/docs/tutorial/event",
"/docs/tutorial/get_started",
"/docs/tutorial/list",
"/docs/tutorial/loro_doc",
"/docs/tutorial/map",
"/docs/tutorial/persistence",
"/docs/tutorial/sync",
"/docs/tutorial/text",
"/docs/tutorial/time_travel",
"/docs/tutorial/tips",
"/docs/tutorial/tree",
"/docs/tutorial/version"
]
};
25 changes: 25 additions & 0 deletions lib/seo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import chineseRoutes from "./chinese-routes.mjs";

const translatedPaths = new Set(chineseRoutes.routes);

/** Return a stable pathname for canonical URLs, without query or fragment. */
export function normalizePath(asPath = "/") {
const pathname = (asPath || "/").split(/[?#]/)[0];
const absolutePath = pathname.startsWith("/") ? pathname : `/${pathname}`;
return absolutePath.replace(/\/+$/, "") || "/";
}

export function hasChineseTranslation(asPath) {
return translatedPaths.has(normalizePath(asPath));
}

export function getPageUrls(asPath) {
const pathname = normalizePath(asPath);
return {
pathname,
canonicalUrl: `https://loro.dev${pathname}`,
chineseUrl: hasChineseTranslation(pathname)
? `https://cn.loro.dev${pathname}`
: undefined,
};
}
3 changes: 2 additions & 1 deletion next-sitemap.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ module.exports = {

generateRobotsTxt: true, // (optional)

// ...other options
// API-reference helpers are components, never indexable pages.
exclude: ["/docs/api/indent", "/docs/api/method"],
};
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"scripts": {
"dev": "next dev",
"build": "NODE_OPTIONS=--max-old-space-size=4096 node gen-rss.js && NODE_OPTIONS=--max-old-space-size=4096 next build",
"postbuild": "NODE_OPTIONS=--max-old-space-size=4096 next-sitemap && NODE_OPTIONS=--max-old-space-size=4096 opennextjs-cloudflare build --skipNextBuild",
"postbuild": "NODE_OPTIONS=--max-old-space-size=4096 next-sitemap && node --test scripts/seo-build.test.mjs && NODE_OPTIONS=--max-old-space-size=4096 opennextjs-cloudflare build --skipNextBuild",
"start": "next start",
"test": "cd deno_scripts && deno run -A ./run_code_blocks.ts ..",
"storybook": "storybook dev -p 6006",
Expand All @@ -13,7 +13,9 @@
"changelog": "node gen-rss.js",
"preview": "NODE_OPTIONS=--max-old-space-size=4096 opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "NODE_OPTIONS=--max-old-space-size=4096 pnpm run build && wrangler deploy",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
"test:seo": "node --test scripts/seo.test.mjs",
"test:seo:build": "node --test scripts/seo-build.test.mjs"
},
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion pages/about.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# About

Loro, created by [Zixuan Chen](http://github.com/https://twitter.com/zx_loro) and
Loro, created by [Zixuan Chen](https://github.com/zxch3n) and
[Leon Zhao](https://github.com/Leeeon233) in 2022, has the vision of empowering
local-first software and simplifying the creation of collaborative applications.

Expand Down
Loading
Loading