From bf4b569638332efa10efd3b54b4cc89f93f2fcc6 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Wed, 30 Sep 2026 11:58:02 +0800 Subject: [PATCH] fix(docs): correct SEO routes, metadata, and links Move API helpers outside routes, gate translation alternates, and add regression checks. The generated llms-full.txt refresh remains a documented draft completion item. --- .github/workflows/seo-tests.yml | 19 +++ README.md | 19 +++ components/LanguageDropdown.tsx | 16 +- .../api-reference/Indent.jsx | 0 .../api-reference/Method.jsx | 0 .../api-reference}/api-reference.module.css | 0 lib/chinese-routes.mjs | 74 +++++++++ lib/seo.mjs | 25 ++++ next-sitemap.config.js | 3 +- package.json | 6 +- pages/about.mdx | 2 +- pages/blog/crdt-is-not-enough.mdx | 24 ++- pages/blog/crdt-richtext.mdx | 2 +- pages/blog/loro-mirror.mdx | 2 +- pages/blog/loro-now-open-source.mdx | 2 +- pages/blog/loro-richtext.mdx | 2 +- pages/docs/api/js.mdx | 6 +- public/blog.xml | 122 +++++++++++++-- public/changelog.xml | 24 +-- public/sitemap-0.xml | 141 +++++++++--------- scripts/seo-build.test.mjs | 48 ++++++ scripts/seo.test.mjs | 54 +++++++ scripts/update-chinese-routes.mjs | 26 ++++ theme.config.jsx | 15 +- 24 files changed, 512 insertions(+), 120 deletions(-) create mode 100644 .github/workflows/seo-tests.yml rename pages/docs/api/indent.jsx => components/api-reference/Indent.jsx (100%) rename pages/docs/api/method.jsx => components/api-reference/Method.jsx (100%) rename {pages/docs/api => components/api-reference}/api-reference.module.css (100%) create mode 100644 lib/chinese-routes.mjs create mode 100644 lib/seo.mjs create mode 100644 scripts/seo-build.test.mjs create mode 100644 scripts/seo.test.mjs create mode 100644 scripts/update-chinese-routes.mjs diff --git a/.github/workflows/seo-tests.yml b/.github/workflows/seo-tests.yml new file mode 100644 index 0000000..626499e --- /dev/null +++ b/.github/workflows/seo-tests.yml @@ -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 diff --git a/README.md b/README.md index 0b888b5..9ecc76f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/components/LanguageDropdown.tsx b/components/LanguageDropdown.tsx index d2d8496..6e8d5b4 100644 --- a/components/LanguageDropdown.tsx +++ b/components/LanguageDropdown.tsx @@ -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"; @@ -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("en"); const closeTimerRef = useRef | null>(null); @@ -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; } @@ -100,7 +104,7 @@ export default function LanguageDropdown() { if (typeof window !== "undefined") { window.location.href = buildTargetUrl(target.domain); } - }, []); + }, [chineseAvailable]); return (
@@ -129,15 +133,17 @@ export default function LanguageDropdown() { ))}
diff --git a/pages/docs/api/indent.jsx b/components/api-reference/Indent.jsx similarity index 100% rename from pages/docs/api/indent.jsx rename to components/api-reference/Indent.jsx diff --git a/pages/docs/api/method.jsx b/components/api-reference/Method.jsx similarity index 100% rename from pages/docs/api/method.jsx rename to components/api-reference/Method.jsx diff --git a/pages/docs/api/api-reference.module.css b/components/api-reference/api-reference.module.css similarity index 100% rename from pages/docs/api/api-reference.module.css rename to components/api-reference/api-reference.module.css diff --git a/lib/chinese-routes.mjs b/lib/chinese-routes.mjs new file mode 100644 index 0000000..7f375c8 --- /dev/null +++ b/lib/chinese-routes.mjs @@ -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" + ] +}; diff --git a/lib/seo.mjs b/lib/seo.mjs new file mode 100644 index 0000000..6e64bb5 --- /dev/null +++ b/lib/seo.mjs @@ -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, + }; +} diff --git a/next-sitemap.config.js b/next-sitemap.config.js index aa758f5..d2f10aa 100644 --- a/next-sitemap.config.js +++ b/next-sitemap.config.js @@ -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"], }; diff --git a/package.json b/package.json index d233745..ca741c0 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/pages/about.mdx b/pages/about.mdx index 2655259..4aaa4df 100644 --- a/pages/about.mdx +++ b/pages/about.mdx @@ -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. diff --git a/pages/blog/crdt-is-not-enough.mdx b/pages/blog/crdt-is-not-enough.mdx index 0b63a9c..d2a2fb0 100644 --- a/pages/blog/crdt-is-not-enough.mdx +++ b/pages/blog/crdt-is-not-enough.mdx @@ -4,9 +4,11 @@ date: 2026/09/21 keywords: crdt, loro, local-first, sync engine, lody description: Drawing on five years of building Loro and Lody, this talk maps the gaps between what CRDT libraries provide and what a production local-first sync engine actually requires. tag: local-first, crdt, sync engine -ogImage: /images/crdt-is-not-enough.png +image: /images/crdt-is-not-enough.png --- +# CRDTs are not enough: From CRDTs to a local-first sync engine + import Authors, { Author } from "../../components/authors"; @@ -23,6 +25,8 @@ Let me introduce myself quickly. My name is Zixuan Chen. I've been working on lo First: what even is local-first software? +## What local-first software promises + ![](./crdt-is-not-enough/slide-03.png) A few quick points. Users control their data, and they can freely switch between different clouds as a backup. The cloud is a backup and a helper, not the authoritative owner. In this architecture, the cloud is just a backup and an extra replica. Local-first is not the same as local-only: local-only means you can only do these things locally, whereas local-first still wants to keep the convenience of syncing across multiple devices. @@ -43,6 +47,8 @@ For Lody, local storage alone was not enough. We wanted users to work across dev So what do we, and the local-first community, imagine a sync engine should be? +## What a local-first sync engine needs to handle + ![](./crdt-is-not-enough/slide-08.png) I can quote Martin Kleppmann's talk at this year's Local-first Conference. For developers, it means you no longer have to worry about failed requests, timeouts, whether your data actually reached the server, or how to tell the user about it. All of those messy problems should just be the sync engine's job. In Lody, we still had to make application-level decisions about when data was ready to display and how to communicate incomplete sync to users. @@ -73,6 +79,8 @@ The essential complexity behind all of it has to move from the application devel That's how you store things locally, what to do about timeouts, how you tell the user, what events you expose, how you express the permissions underneath, how you switch backends, how you provide P2P, and how you provide end-to-end encryption at the same time. All of that becomes the sync engine's responsibility. +## What CRDT libraries already provide + ![](./crdt-is-not-enough/slide-15.png) Today, CRDT libraries already give you these capabilities. Loro, Yjs, and Automerge can all model structured documents like JSON and give you eventual consistency. As long as every replica ends up with the same set of updates, the final state is guaranteed to be the same. They also give you incremental sync; and for text, rich text, lists, and so on, the merge results come out as intuitive as possible. There's a lot of research behind this, and the results have been folded into these CRDT libraries. Out-of-order and duplicate updates during sync are covered too. You don't have to care when an update arrives; you can safely resend an update, and it won't be applied twice. These are the responsibilities of the CRDT core. The surrounding ecosystems already provide more: [automerge-repo](https://automerge.org/docs/reference/concepts/) manages multiple documents, networking, and storage, while Yjs has [connection providers](https://docs.yjs.dev/ecosystem/connection-provider) and persistence adapters such as [y-indexeddb](https://docs.yjs.dev/ecosystem/database-provider/y-indexeddb). Those building blocks still leave product decisions about which documents to discover and load, and when related updates are ready to display together. @@ -91,6 +99,8 @@ We worked backwards, step by step, while building Lody, and we've accumulated so The [Lody](https://github.com/LodyAI/Lody) project should have seven thousand-something commits by now, and a lot of them are sync-related upgrades and rework. +## Discovering and loading multiple documents + ![](./crdt-is-not-enough/slide-20.png) Lody is an agent control plane. It needs to sync all kinds of conversation information, and in the future there'll be more document information too. That's thousands of documents whose data we need to sync. How do we do lazy loading? How do we sync the whole repo? That was the first problem we ran into. You can't load the full content of every document the moment someone opens the app. Document convergence alone does not define a directory of available documents or decide which content to fetch. Multi-document tools such as automerge-repo already exist; our task was to design a metadata-first directory and loading policy for Lody on top of Loro. @@ -106,6 +116,8 @@ So we built a new CRDT called Flock, and we'll open-source it in the future. It Flock has gone through many iterations. At the very beginning we used a fairly simple JSON format, and it quickly couldn't keep up. Then we switched to a V1 binary format, but it was over-engineered and error-prone. After that we moved to a columnar V2 data format. V1 was somewhat inspired by SQLite's in-place update storage; V2 became a different file format, inspired by database design. Its performance and stability are much better than before, and users never noticed the whole switch. +## Local storage, search, and prefetching + ![](./crdt-is-not-enough/slide-23.png) Then storing data locally and deciding where to query it both became new problems. At first we tried to hook up SQLite on the web. There are practices around OPFS now, and Notion does the same thing. But we quickly found that path complicated and hard to pull off. Every browser tab is independent, and SQLite itself can only have one writer. If you have multiple tabs, it's as if every tab is fighting over the writer role. So the architecture has to become: the browser elects a leader, the leader holds SQLite, and it handles all the writes. That whole setup is very complex. We looked around at the time and couldn't find an open-source solution we could just pick up and use. That was February of this year — I don't know if anything's changed since. Later we switched to IndexedDB. But that's only a small part of the problem. Then there's full-text search: in a pure cloud architecture, that's actually an easy problem — just use Elastic, or give Postgres some full-text search extension. But doing this locally, how do you design the UX? Especially in the browser, where you don't load all the data up front. On top of that, we try to wrap as much of the cloud data as possible in end-to-end encryption. How to do that part is still unsolved, and we're still experimenting. I think in the future we might be able to use semantic search and combine it with some encryption techniques. That's another new problem. @@ -126,6 +138,8 @@ And how to prefetch: we've iterated on that several times, because you can't hog CRDTs move the computational complexity from the cloud to the local device, so there's a real local performance cost. Right now we've moved some of the computation onto a worker. +## Consistent visibility across documents + ![](./crdt-is-not-enough/slide-27.png) The notification example at the start comes from splitting one user action across two streams. Sending a message updates both the conversation document and the Flock metadata used by the conversation list. Our engine has no cross-stream transactions. The two streams are delivered independently, so a receiving client that shows a notification as soon as metadata arrives can lead the user to a conversation whose new message has not arrived yet. @@ -146,6 +160,8 @@ That leaves the application with extra work in its data model, sync scheduling, Now let's talk about how to do the cloud. +## Designing a hosted sync backend + ![](./crdt-is-not-enough/slide-31.png) Why talk about the cloud at all? It comes back to the Git and GitHub example. These days basically every developer has a GitHub account, and GitHub is a very successful hosting service. Git itself is a local-first protocol, but most users still want a hosted product that works out of the box. That doesn't go against the local-first ideal. In the future there'll be different hosting backends under the same protocol, plus a self-host option. That way users don't have to worry about a lot of details. Things like organizations, permissions, and CI hooks on GitHub just work out of the box. @@ -164,6 +180,8 @@ With that usage pattern, either the read/write cost is very high, or the long-co One counterintuitive thing: a CRDT itself requires the server to know very little. Especially once you add end-to-end encryption, the server just needs to be a relay. CRDTs let smart clients resolve conflicts and do the computation directly — unlike algorithms like OT, which need the server to do that computation. So it's very counterintuitive that the server still has to do a lot of complex computation and pay a memory cost. +## An append-only log with Durable Streams + ![](./crdt-is-not-enough/slide-34.png) Later we switched to a different model: we adopted the Durable Streams protocol from the ElectricSQL team and implemented our own backend. Durable Streams is roughly this: each stream maps to a URL, and a stream is like an append-only file. Every appended update can be broadcast out, and you can also get an offset on that stream and fetch everything after it, which is how CRDT catch-up works. Because the property of a CRDT is: as long as every replica gets the same set of updates, they all reach a consistent state. So essentially, we just need every replica to get the same set of updates. A concept like an offset is the most direct and simple thing, and it needs the least state. The server just needs to provide that kind of streaming log. @@ -179,6 +197,8 @@ For a remote cursor, locally you just need to store an offset. On top of this dumb log, we made some extensions to Durable Streams. Clients can publish snapshots. There's a bootstrap protocol, so you can start from a snapshot and then catch up with the tail. If a CRDT only keeps updates, some metadata is hard to compress; but if you turn all the updates before a certain point into a snapshot, the snapshot can be compressed very small. So we gave clients the ability to send snapshots. Then we added an ephemeral channel for syncing cursors and presence. And on top of that, a primitive like append-CAS, which is what end-to-end encryption depends on. +## End-to-end encryption and access control + ![](./crdt-is-not-enough/slide-37.png) If you add end-to-end encryption, the server doesn't need to change much at all — it's mainly the client that embeds the capability. And that gives you a nice property: even if the sync backend is compromised, the attacker can't get the data and can't forge data. @@ -199,6 +219,8 @@ Like I said, the cloud only handles bytes, so it's very efficient. The overall cloud architecture is roughly: client to gateway; the gateway does authentication; behind it is Raft. Each stream lands in a Raft group; hot data lives on the machines in that Raft group, and cold data goes to object storage. +## The user experience and remaining challenges + ![](./crdt-is-not-enough/slide-41.png) So what user experience does this give you? diff --git a/pages/blog/crdt-richtext.mdx b/pages/blog/crdt-richtext.mdx index 2e93c67..aea7f05 100644 --- a/pages/blog/crdt-richtext.mdx +++ b/pages/blog/crdt-richtext.mdx @@ -14,7 +14,7 @@ import Authors, { Author } from "../../components/authors"; diff --git a/pages/blog/loro-mirror.mdx b/pages/blog/loro-mirror.mdx index b6ff23a..9bf65f9 100644 --- a/pages/blog/loro-mirror.mdx +++ b/pages/blog/loro-mirror.mdx @@ -14,7 +14,7 @@ import Authors, { Author } from "../../components/authors"; diff --git a/pages/blog/loro-now-open-source.mdx b/pages/blog/loro-now-open-source.mdx index 94c8f6b..09ebf1f 100644 --- a/pages/blog/loro-now-open-source.mdx +++ b/pages/blog/loro-now-open-source.mdx @@ -16,7 +16,7 @@ import Authors, { Author } from "../../components/authors"; diff --git a/pages/blog/loro-richtext.mdx b/pages/blog/loro-richtext.mdx index 0d605b5..e855441 100644 --- a/pages/blog/loro-richtext.mdx +++ b/pages/blog/loro-richtext.mdx @@ -17,7 +17,7 @@ import Authors, { Author } from "../../components/authors"; diff --git a/pages/docs/api/js.mdx b/pages/docs/api/js.mdx index 4800d29..120d125 100644 --- a/pages/docs/api/js.mdx +++ b/pages/docs/api/js.mdx @@ -1,6 +1,6 @@ -import styles from "./api-reference.module.css"; -import Indent from "./indent"; -import Method from "./method"; +import styles from "../../../components/api-reference/api-reference.module.css"; +import Indent from "../../../components/api-reference/Indent"; +import Method from "../../../components/api-reference/Method";
diff --git a/public/blog.xml b/public/blog.xml index 18faba3..68dcb82 100644 --- a/public/blog.xml +++ b/public/blog.xml @@ -4,14 +4,118 @@ Loro Blog https://loro.dev/blog/ Updates and stories from the Loro team. - Tue, 16 Jun 2026 02:49:05 GMT + Wed, 30 Sep 2026 01:31:16 GMT https://validator.w3.org/feed/docs/rss2.html https://github.com/jpmonette/feed + + <![CDATA[CRDTs are not enough: From CRDTs to a local-first sync engine]]> + https://loro.dev/blog/crdt-is-not-enough + https://loro.dev/blog/crdt-is-not-enough + Mon, 21 Sep 2026 07:00:00 GMT + + CRDTs are not enough: From CRDTs to a local-first sync engine +

A user sees a new-message notification, opens the conversation, and finds no new message. The conversation index has synced, but the message body has not. Both documents can eventually converge while the user still sees an incomplete operation. This is one of the gaps we encountered while building a local-first sync engine on top of CRDTs.

+

+

Let me introduce myself quickly. My name is Zixuan Chen. I've been working on loro.dev since 2022. It's a CRDT library together with Leon. Then, toward the end of 2025, we started building lody.ai on top of Loro. Lody is a local-first control plane for team AI agents. Through Lody, we started from real product needs and from polishing the UI/UX, and worked backwards to figure out what a local-first sync engine should actually look like these days. Along the way, we found a lot of things that are still missing. So today I want to share what we've learned.

+

+

First: what even is local-first software?

+

What local-first software promises

+

+

A few quick points. Users control their data, and they can freely switch between different clouds as a backup. The cloud is a backup and a helper, not the authoritative owner. In this architecture, the cloud is just a backup and an extra replica. Local-first is not the same as local-only: local-only means you can only do these things locally, whereas local-first still wants to keep the convenience of syncing across multiple devices.

+

+

It promises users five things. Instant open, because reads and writes all happen locally. It works offline. Multi-device collaboration. You can swap out the backend, so a vendor going out of business doesn't take your software down with it. And your data is yours. That last one really has two sides. One is that the software provider can't lock me out of my own software. The other is privacy — my data can't be read by the provider whenever they feel like it.

+

+

The classic example is Git. Git hasn't done the privacy part yet, but it's already very close to the ideal shape of local-first software. It defines, as a protocol, how the local side and the remote side exchange data. And you can switch to a different backend: if GitHub goes down, or you just don't want to use GitHub anymore, you can change the origin, switch to GitLab, or self-host it yourself.

+

+

For Lody, local storage alone was not enough. We wanted users to work across devices and collaborate without managing synchronization themselves, while retaining control of their data. That combination shaped our requirements for the sync engine.

+

So what do we, and the local-first community, imagine a sync engine should be?

+

What a local-first sync engine needs to handle

+

+

I can quote Martin Kleppmann's talk at this year's Local-first Conference. For developers, it means you no longer have to worry about failed requests, timeouts, whether your data actually reached the server, or how to tell the user about it. All of those messy problems should just be the sync engine's job. In Lody, we still had to make application-level decisions about when data was ready to display and how to communicate incomplete sync to users.

+

+

What people want is one unified, general-purpose sync engine that absorbs all of this: how to handle failed requests, what to do when data doesn't reach the server, how the frontend tells the user, and how permissions are managed so that whether a user can edit something is intuitive and obvious. And also how the local side and the backend persist data, and how you switch between providers.

+

+

From the end user's point of view, local-first delivers two very direct benefits: it's fast, and it's secure. A sync engine like this can give end users both.

+

+

A local-first app can support ticket sales, inventory management, payments, or room bookings. But an operation such as allocating the last available seat requires coordination before it can be confirmed; independent offline edits alone cannot guarantee that the seat is sold only once. The app can still support local reads and drafts while coordinating operations that enforce these shared constraints.

+

Creative work — code, documents, video editing, and music — often allows more edits to happen independently and merge later. That makes it a natural fit for this style of synchronization.

+

+

The essential complexity behind all of it has to move from the application developer to the sync engine.

+

+

That's how you store things locally, what to do about timeouts, how you tell the user, what events you expose, how you express the permissions underneath, how you switch backends, how you provide P2P, and how you provide end-to-end encryption at the same time. All of that becomes the sync engine's responsibility.

+

What CRDT libraries already provide

+

+

Today, CRDT libraries already give you these capabilities. Loro, Yjs, and Automerge can all model structured documents like JSON and give you eventual consistency. As long as every replica ends up with the same set of updates, the final state is guaranteed to be the same. They also give you incremental sync; and for text, rich text, lists, and so on, the merge results come out as intuitive as possible. There's a lot of research behind this, and the results have been folded into these CRDT libraries. Out-of-order and duplicate updates during sync are covered too. You don't have to care when an update arrives; you can safely resend an update, and it won't be applied twice. These are the responsibilities of the CRDT core. The surrounding ecosystems already provide more: automerge-repo manages multiple documents, networking, and storage, while Yjs has connection providers and persistence adapters such as y-indexeddb. Those building blocks still leave product decisions about which documents to discover and load, and when related updates are ready to display together.

+

+

For Lody, the questions were how to combine these pieces: which documents should a device fetch, what should the UI show while content is still arriving, and how should permissions appear to users? Supporting P2P would also require us to decide how identity and access control work without relying on our hosted backend.

+

We worked backwards, step by step, while building Lody, and we've accumulated some experience. So let me share a bit of it, just to get the conversation going.

+

+

The Lody project should have seven thousand-something commits by now, and a lot of them are sync-related upgrades and rework.

+

Discovering and loading multiple documents

+

+

Lody is an agent control plane. It needs to sync all kinds of conversation information, and in the future there'll be more document information too. That's thousands of documents whose data we need to sync. How do we do lazy loading? How do we sync the whole repo? That was the first problem we ran into. You can't load the full content of every document the moment someone opens the app. Document convergence alone does not define a directory of available documents or decide which content to fetch. Multi-document tools such as automerge-repo already exist; our task was to design a metadata-first directory and loading policy for Lody on top of Loro.

+

+

So we built a new CRDT called Flock, and we'll open-source it in the future. It syncs a directory, essentially. Like when you open Notion, the first thing you see is which documents exist. Or when you open a Notion database, you first see all the rows and their metadata. Only after you click into a specific document do you pull its content. Flock is the CRDT responsible for syncing that directory behind the scenes. We use it to sync the titles of all the conversations in Lody — the information you need to see first.

+

+

Flock has gone through many iterations. At the very beginning we used a fairly simple JSON format, and it quickly couldn't keep up. Then we switched to a V1 binary format, but it was over-engineered and error-prone. After that we moved to a columnar V2 data format. V1 was somewhat inspired by SQLite's in-place update storage; V2 became a different file format, inspired by database design. Its performance and stability are much better than before, and users never noticed the whole switch.

+

Local storage, search, and prefetching

+

+

Then storing data locally and deciding where to query it both became new problems. At first we tried to hook up SQLite on the web. There are practices around OPFS now, and Notion does the same thing. But we quickly found that path complicated and hard to pull off. Every browser tab is independent, and SQLite itself can only have one writer. If you have multiple tabs, it's as if every tab is fighting over the writer role. So the architecture has to become: the browser elects a leader, the leader holds SQLite, and it handles all the writes. That whole setup is very complex. We looked around at the time and couldn't find an open-source solution we could just pick up and use. That was February of this year — I don't know if anything's changed since. Later we switched to IndexedDB. But that's only a small part of the problem. Then there's full-text search: in a pure cloud architecture, that's actually an easy problem — just use Elastic, or give Postgres some full-text search extension. But doing this locally, how do you design the UX? Especially in the browser, where you don't load all the data up front. On top of that, we try to wrap as much of the cloud data as possible in end-to-end encryption. How to do that part is still unsolved, and we're still experimenting. I think in the future we might be able to use semantic search and combine it with some encryption techniques. That's another new problem.

+

+

Metadata-first — syncing on demand, syncing the metadata first, like I said.

+

+

And how to prefetch: we've iterated on that several times, because you can't hog all the frontend's network and performance, but you still have to take care of the user experience. Lody syncs a lot of conversations, and if every time a conversation updates, you have to wait again after clicking in, the experience gets much worse. There's a lot of fine-tuning to do there.

+

+

CRDTs move the computational complexity from the cloud to the local device, so there's a real local performance cost. Right now we've moved some of the computation onto a worker.

+

Consistent visibility across documents

+

+

The notification example at the start comes from splitting one user action across two streams. Sending a message updates both the conversation document and the Flock metadata used by the conversation list. Our engine has no cross-stream transactions. The two streams are delivered independently, so a receiving client that shows a notification as soon as metadata arrives can lead the user to a conversation whose new message has not arrived yet.

+

The user-visible requirement is simple: a client should not show a new-message notification until the corresponding message is available locally. Writing both updates durably on the sender, committing them together on the server, and displaying them together on the receiver are distinct guarantees. Even a server-side atomic commit would not, by itself, make clients fetching separate streams display both updates together.

+

+

Convergence within each document does not provide that visibility guarantee across documents. The sync engine and application need a way to track the relationship between the updates and decide when they can be shown. We have not yet built a general mechanism for this in Lody.

+

That leaves the application with extra work in its data model, sync scheduling, and UI. Delayed delivery also needs different treatment from a genuine editing conflict: a missing message may still be on its way.

+

+

Now let's talk about how to do the cloud.

+

Designing a hosted sync backend

+

+

Why talk about the cloud at all? It comes back to the Git and GitHub example. These days basically every developer has a GitHub account, and GitHub is a very successful hosting service. Git itself is a local-first protocol, but most users still want a hosted product that works out of the box. That doesn't go against the local-first ideal. In the future there'll be different hosting backends under the same protocol, plus a self-host option. That way users don't have to worry about a lot of details. Things like organizations, permissions, and CI hooks on GitHub just work out of the box.

+

+

At first we tried Cloudflare's Durable Objects. We tried to let one Durable Object manage the entire repo directly: one user or one org maps to one repo, and we forward everything through that Durable Object, syncing data in that shape. Inside the Durable Object we'd load the CRDT document, compute the version diff, and do incremental sync. But we quickly found it couldn't really carry that usage pattern or that volume. To compare versions, you either pull the relevant updates, load them into the CRDT document, and then compare — which is very expensive: high memory cost, and a lot of data to read and load. Or you store the version and other info in the SQLite inside the Durable Object, but that also has fairly high read/write costs, and you have to update the version vector on every update. And a repo also has different permissions: some people are read-only, some can write; some rooms are open to some people and not to others.

+

With that usage pattern, either the read/write cost is very high, or the long-connection cost is high, because Durable Objects bill by connection time. However long a WebSocket connection stays alive, that's the duration cost. To avoid that cost, you can evict all memory and go into hibernation. In that state the WebSocket connection stays open, but you're not billed for that time. At the same time, the Durable Object's other in-memory state gets evicted too, so all state has to live in its built-in SQLite. So all those state updates turn back into SQLite read/write cost. At the time, we found that cost was still a bit high.

+

+

One counterintuitive thing: a CRDT itself requires the server to know very little. Especially once you add end-to-end encryption, the server just needs to be a relay. CRDTs let smart clients resolve conflicts and do the computation directly — unlike algorithms like OT, which need the server to do that computation. So it's very counterintuitive that the server still has to do a lot of complex computation and pay a memory cost.

+

An append-only log with Durable Streams

+

+

Later we switched to a different model: we adopted the Durable Streams protocol from the ElectricSQL team and implemented our own backend. Durable Streams is roughly this: each stream maps to a URL, and a stream is like an append-only file. Every appended update can be broadcast out, and you can also get an offset on that stream and fetch everything after it, which is how CRDT catch-up works. Because the property of a CRDT is: as long as every replica gets the same set of updates, they all reach a consistent state. So essentially, we just need every replica to get the same set of updates. A concept like an offset is the most direct and simple thing, and it needs the least state. The server just needs to provide that kind of streaming log.

+

+

For a remote cursor, locally you just need to store an offset.

+

+

On top of this dumb log, we made some extensions to Durable Streams. Clients can publish snapshots. There's a bootstrap protocol, so you can start from a snapshot and then catch up with the tail. If a CRDT only keeps updates, some metadata is hard to compress; but if you turn all the updates before a certain point into a snapshot, the snapshot can be compressed very small. So we gave clients the ability to send snapshots. Then we added an ephemeral channel for syncing cursors and presence. And on top of that, a primitive like append-CAS, which is what end-to-end encryption depends on.

+

End-to-end encryption and access control

+

+

If you add end-to-end encryption, the server doesn't need to change much at all — it's mainly the client that embeds the capability. And that gives you a nice property: even if the sync backend is compromised, the attacker can't get the data and can't forge data.

+

+

But our limitation right now is that the authentication we designed is still centralized — we haven't gotten it to a decentralized state. For the end-to-end encryption protocol, we also chose a server-based linear log — the stream I just described. Projects such as Keyhive explore decentralized access control. We have not integrated such a design into Lody; the linear log is the approach we chose for our current system.

+

+

Like I said, the cloud only handles bytes, so it's very efficient.

+

+

The overall cloud architecture is roughly: client to gateway; the gateway does authentication; behind it is Raft. Each stream lands in a Raft group; hot data lives on the machines in that Raft group, and cold data goes to object storage.

+

The user experience and remaining challenges

+

+

So what user experience does this give you?

+

+

One is fast loading, because data is always local, so it can load right away. Another is fast syncing, because CRDTs already handle incremental sync automatically. And end-to-end encryption, which is something Lody is working on — we'll probably ship it next month.

+

+

The difference users can feel — here are two comments from X: Lody's sync feels smooth. Buttery smooth.

+

+

To sum up, the remaining challenges go beyond document convergence: authentication, end-to-end encryption, consistent visibility across documents, and intuitive UI/UX in decentralized systems.

+]]>
+
<![CDATA[Mergeable Containers: Fixing Concurrent Child Creation]]> https://loro.dev/blog/mergeable-containers https://loro.dev/blog/mergeable-containers - Tue, 09 Jun 2026 00:00:00 GMT + Tue, 09 Jun 2026 07:00:00 GMT Mergeable Containers: Fixing Concurrent Child Creation

Mergeable Containers overview

@@ -330,7 +434,7 @@ console.log("automerge pre-created ->", JSON.stringify(ok.days[&quo <![CDATA[Loro Protocol]]> https://loro.dev/blog/loro-protocol https://loro.dev/blog/loro-protocol - Thu, 30 Oct 2025 00:00:00 GMT + Thu, 30 Oct 2025 07:00:00 GMT Loro Protocol

@@ -416,7 +520,7 @@ ephAdaptor.getStore().set("cursor", { x: 100, y: 100 }); <![CDATA[Loro Mirror: Make UI State Collaborative by Mirroring to CRDTs]]> https://loro.dev/blog/loro-mirror https://loro.dev/blog/loro-mirror - Mon, 22 Sep 2025 00:00:00 GMT + Mon, 22 Sep 2025 07:00:00 GMT Loro Mirror: Make UI State Collaborative by Mirroring to CRDTs

@@ -629,7 +733,7 @@ const undo = useMemo(() => new UndoManager(doc), [doc]); <![CDATA[Loro 1.0]]> https://loro.dev/blog/v1.0 https://loro.dev/blog/v1.0 - Wed, 23 Oct 2024 00:00:00 GMT + Wed, 23 Oct 2024 07:00:00 GMT Loro 1.0

Loro is a Conflict-free Replicated Data Type (CRDT) @@ -1341,7 +1445,7 @@ If you're interested in using Loro, welcome to join our <![CDATA[Movable tree CRDTs and Loro's implementation]]> https://loro.dev/blog/movable-tree https://loro.dev/blog/movable-tree - Thu, 18 Jul 2024 00:00:00 GMT + Thu, 18 Jul 2024 07:00:00 GMT Movable tree CRDTs and Loro's implementation

@@ -1625,7 +1729,7 @@ let nodeData: LoroMap = node.data; <![CDATA[Introduction to Loro's Rich Text CRDT]]> https://loro.dev/blog/loro-richtext https://loro.dev/blog/loro-richtext - Mon, 22 Jan 2024 00:00:00 GMT + Mon, 22 Jan 2024 08:00:00 GMT Introduction to Loro's Rich Text CRDT

@@ -2327,7 +2431,7 @@ any proposals for collaboration, please reach out to crdt-richtext: Rust implementation of Peritext and Fugue

Presenting an early Rust crate that combines Peritext and Fugue's power with impressive performance, tailored specifically for rich text. This work later informed Loro's rich text CRDT.

diff --git a/public/changelog.xml b/public/changelog.xml index 491216e..36584a0 100644 --- a/public/changelog.xml +++ b/public/changelog.xml @@ -4,14 +4,14 @@ Loro Changelog https://loro.dev/changelog/ Changelog of Loro CRDT. - Tue, 16 Jun 2026 02:49:05 GMT + Wed, 30 Sep 2026 01:31:16 GMT https://validator.w3.org/feed/docs/rss2.html https://github.com/jpmonette/feed <![CDATA[Release Loro v1.9.0]]> https://loro.dev/changelog/v1.9.0 https://loro.dev/changelog/v1.9.0 - Mon, 10 Nov 2025 00:00:00 GMT + Mon, 10 Nov 2025 08:00:00 GMT Highlights
  • JSONPath queries are now RFC 9535 compliant across both Rust and WASM bindings. A new parser, evaluator, and conformance test suite (PR #848, commit 21d13218) unlock richer filters like in and contains while delivering clearer error messages. Thanks @zolero!
  • @@ -46,7 +46,7 @@ <![CDATA[Release Loro v1.8.0]]> https://loro.dev/changelog/v1.8.0 https://loro.dev/changelog/v1.8.0 - Mon, 22 Sep 2025 00:00:00 GMT + Mon, 22 Sep 2025 07:00:00 GMT Events now fire synchronously

    In the JavaScript package loro-crdt@1.8.0, we changed event emission from “after a microtask” to synchronous dispatch. This makes event handling simpler and less error‑prone.

    Why we changed it

    @@ -72,7 +72,7 @@ <![CDATA[Release Loro v1.6.0]]> https://loro.dev/changelog/v1.6.0 https://loro.dev/changelog/v1.6.0 - Fri, 29 Aug 2025 00:00:00 GMT + Fri, 29 Aug 2025 07:00:00 GMT Snapshot import speed is now 2x.

    This is implemented by skipping the scan when importing a snapshot, which avoids running the decompression during the import process (but we need to ensure the parent-child link is still accessible in Arena). It also skips the checksum check in the import_all method on MemKV because we already check the checksum in the header of the snapshot/update.

    v1.0.0 vs v1.6.0

    @@ -172,7 +172,7 @@ <![CDATA[Release Loro Inspector v0.1.0]]> https://loro.dev/changelog/inspector-v0.1.0 https://loro.dev/changelog/inspector-v0.1.0 - Wed, 30 Apr 2025 00:00:00 GMT + Wed, 30 Apr 2025 07:00:00 GMT Try it here: Loro Inspector

    Now you can directly browse the current state and complete edit history of your Loro documents in the browser. You can also use this tool to time travel to any version @@ -183,7 +183,7 @@ in the history of your Loro document.

    <![CDATA[Release Loro v1.5.0]]> https://loro.dev/changelog/v1.5.0 https://loro.dev/changelog/v1.5.0 - Fri, 04 Apr 2025 00:00:00 GMT + Fri, 04 Apr 2025 07:00:00 GMT New

    1. New Hooks

    doc.subscribePreCommit(listener) - Modify commit options before processing:

    @@ -284,7 +284,7 @@ shared and accessed in parallel across multiple threads without triggering the p <![CDATA[Release Loro v1.4.7]]> https://loro.dev/changelog/v1.4.7 https://loro.dev/changelog/v1.4.7 - Tue, 01 Apr 2025 00:00:00 GMT + Tue, 01 Apr 2025 07:00:00 GMT New
    • You can get the version of Loro by LORO_VERSION
    • @@ -308,7 +308,7 @@ shared and accessed in parallel across multiple threads without triggering the p <![CDATA[Release Loro v1.4.0]]> https://loro.dev/changelog/v1.4.0 https://loro.dev/changelog/v1.4.0 - Thu, 13 Feb 2025 00:00:00 GMT + Thu, 13 Feb 2025 08:00:00 GMT New
      • add unsubscribe() for Subscription.
      • @@ -326,7 +326,7 @@ shared and accessed in parallel across multiple threads without triggering the p <![CDATA[Release Loro v1.3.0]]> https://loro.dev/changelog/v1.3.0 https://loro.dev/changelog/v1.3.0 - Thu, 09 Jan 2025 00:00:00 GMT + Thu, 09 Jan 2025 08:00:00 GMT New
        • UndoManager's onPush now can access the change event.
        • @@ -353,7 +353,7 @@ shared and accessed in parallel across multiple threads without triggering the p <![CDATA[Release Loro v1.2.0]]> https://loro.dev/changelog/v1.2.0 https://loro.dev/changelog/v1.2.0 - Tue, 10 Dec 2024 00:00:00 GMT + Tue, 10 Dec 2024 08:00:00 GMT New
          • Add isDeleted() method to all container types (Text, Map, List, Tree, etc.)
          • @@ -386,7 +386,7 @@ shared and accessed in parallel across multiple threads without triggering the p <![CDATA[Release Loro v1.1.0]]> https://loro.dev/changelog/v1.1.0 https://loro.dev/changelog/v1.1.0 - Sat, 09 Nov 2024 00:00:00 GMT + Sat, 09 Nov 2024 08:00:00 GMT New

            LoroDoc

              @@ -431,7 +431,7 @@ shared and accessed in parallel across multiple threads without triggering the p <![CDATA[Release Loro v1.0.0]]> https://loro.dev/changelog/v1.0.0-beta https://loro.dev/changelog/v1.0.0-beta - Mon, 21 Oct 2024 00:00:00 GMT + Mon, 21 Oct 2024 07:00:00 GMT We are very excited to announce the release of Loro v1.0, a major milestone.

              It has a stable encoding format, faster document import and export speed, better version control capabilities, and a shallow snapshot. For more information, please check the blog.

              The following are the specific API changes:

              diff --git a/public/sitemap-0.xml b/public/sitemap-0.xml index 84d917d..ef24a2e 100644 --- a/public/sitemap-0.xml +++ b/public/sitemap-0.xml @@ -1,74 +1,73 @@ -https://loro.dev2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/about2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/blog2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/blog/crdt-richtext2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/blog/loro-mirror2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/blog/loro-now-open-source2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/blog/loro-protocol2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/blog/loro-richtext2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/blog/mergeable-containers2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/blog/movable-tree2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/blog/v1.02026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/changelog2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/changelog/inspector-v0.1.02026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/changelog/v1.0.0-beta2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/changelog/v1.1.02026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/changelog/v1.2.02026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/changelog/v1.3.02026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/changelog/v1.4.02026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/changelog/v1.4.72026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/changelog/v1.5.02026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/changelog/v1.6.02026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/changelog/v1.8.02026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/changelog/v1.9.02026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/advanced/cid2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/advanced/import_batch2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/advanced/inspector2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/advanced/jsonpath2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/advanced/timestamp2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/advanced/undo2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/advanced/version_deep_dive2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/api/indent2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/api/js2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/api/method2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/concepts/attached_detached2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/concepts/choose_crdt_type2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/concepts/container2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/concepts/crdt2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/concepts/cursor_stable_positions2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/concepts/event_graph_walker2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/concepts/frontiers2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/concepts/import_status2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/concepts/operations_changes2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/concepts/oplog_docstate2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/concepts/peerid_management2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/concepts/shallow_snapshots2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/concepts/transaction_model2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/concepts/version_vector2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/concepts/when_not_crdt2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/examples2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/llm2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/performance2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/performance/docsize2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/performance/native2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/composition2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/counter2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/cursor2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/encoding2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/ephemeral2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/event2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/get_started2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/list2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/loro_doc2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/map2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/persistence2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/sync2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/text2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/time_travel2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/tips2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/tree2026-06-16T02:51:04.429Zdaily0.7 -https://loro.dev/docs/tutorial/version2026-06-16T02:51:04.429Zdaily0.7 +https://loro.dev2026-09-30T01:32:35.735Zdaily0.7 +https://loro.dev/about2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/blog2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/blog/crdt-is-not-enough2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/blog/crdt-richtext2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/blog/loro-mirror2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/blog/loro-now-open-source2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/blog/loro-protocol2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/blog/loro-richtext2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/blog/mergeable-containers2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/blog/movable-tree2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/blog/v1.02026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/changelog2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/changelog/inspector-v0.1.02026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/changelog/v1.0.0-beta2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/changelog/v1.1.02026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/changelog/v1.2.02026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/changelog/v1.3.02026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/changelog/v1.4.02026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/changelog/v1.4.72026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/changelog/v1.5.02026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/changelog/v1.6.02026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/changelog/v1.8.02026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/changelog/v1.9.02026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/advanced/cid2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/advanced/import_batch2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/advanced/inspector2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/advanced/jsonpath2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/advanced/timestamp2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/advanced/undo2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/advanced/version_deep_dive2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/api/js2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/concepts/attached_detached2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/concepts/choose_crdt_type2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/concepts/container2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/concepts/crdt2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/concepts/cursor_stable_positions2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/concepts/event_graph_walker2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/concepts/frontiers2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/concepts/import_status2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/concepts/operations_changes2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/concepts/oplog_docstate2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/concepts/peerid_management2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/concepts/shallow_snapshots2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/concepts/transaction_model2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/concepts/version_vector2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/concepts/when_not_crdt2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/examples2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/llm2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/performance2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/performance/docsize2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/performance/native2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/composition2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/counter2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/cursor2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/encoding2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/ephemeral2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/event2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/get_started2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/list2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/loro_doc2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/map2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/persistence2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/sync2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/text2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/time_travel2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/tips2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/tree2026-09-30T01:32:35.736Zdaily0.7 +https://loro.dev/docs/tutorial/version2026-09-30T01:32:35.736Zdaily0.7 \ No newline at end of file diff --git a/scripts/seo-build.test.mjs b/scripts/seo-build.test.mjs new file mode 100644 index 0000000..67f95c7 --- /dev/null +++ b/scripts/seo-build.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import test from "node:test"; +import { getPageUrls } from "../lib/seo.mjs"; + +const root = new URL("../", import.meta.url); +const read = (path) => readFileSync(new URL(path, root), "utf8"); +const routes = readdirSync(new URL("pages/", root), { recursive: true }) + .filter((path) => /\.(md|mdx)$/.test(path) && !path.split("/").some((part) => part.startsWith("_"))) + .map((path) => `/${path.replace(/\.(md|mdx)$/, "").replace(/(^|\/)index$/, "")}`.replace(/\/$/, "") || "/"); + +function links(html) { + return [...html.matchAll(/]*>/gi)].map(([tag]) => + Object.fromEntries([...tag.matchAll(/([\w-]+)="([^"]*)"/g)].map(([, key, value]) => [key.toLowerCase(), value])) + ); +} + +for (const route of routes) { + test(`built metadata: ${route}`, () => { + const html = read(`.next/server/pages${route === "/" ? "/index" : route}.html`); + const tags = links(html); + const { canonicalUrl, chineseUrl } = getPageUrls(route); + assert.deepEqual(tags.filter((link) => link.rel === "canonical").map((link) => link.href), [canonicalUrl]); + assert.deepEqual(tags.filter((link) => link.hreflang === "zh").map((link) => link.href), chineseUrl ? [chineseUrl] : []); + assert.match(html, /[^<]+<\/title>/); + const chineseButton = [...html.matchAll(/<button\b([^>]*)>([\s\S]*?)<\/button>/g)] + .find(([, , content]) => content.includes("中文")); + assert.ok(chineseButton, "language menu must remain available"); + assert.equal(/\bdisabled(?:=|\s|$)/.test(chineseButton[1]), !chineseUrl); + }); +} + +test("built routing manifest and sitemap do not publish API helper pages", () => { + const manifest = JSON.parse(read(".next/server/pages-manifest.json")); + for (const route of ["/docs/api/indent", "/docs/api/method"]) { + assert.equal(manifest[route], undefined); + assert.ok(!read("public/sitemap-0.xml").includes(`${route}<`)); + } + assert.ok(manifest["/docs/api/js"]); +}); + + +test("CRDT article renders a title, topic headings, and its own social image", () => { + const html = read(".next/server/pages/blog/crdt-is-not-enough.html"); + assert.equal([...html.matchAll(/<h1\b/g)].length, 1); + assert.ok([...html.matchAll(/<h2\b/g)].length >= 10); + assert.match(html, /property="og:image" content="https:\/\/loro\.dev\/images\/crdt-is-not-enough\.png"/); +}); diff --git a/scripts/seo.test.mjs b/scripts/seo.test.mjs new file mode 100644 index 0000000..d9b52b0 --- /dev/null +++ b/scripts/seo.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import test from "node:test"; +import chineseRoutes from "../lib/chinese-routes.mjs"; +import { getPageUrls, hasChineseTranslation, normalizePath } from "../lib/seo.mjs"; + +const root = new URL("../", import.meta.url); +const read = (path) => readFileSync(new URL(path, root), "utf8"); + +test("canonical paths exclude queries, fragments, and trailing slashes", () => { + for (const path of [undefined, "", "/", "/?utm_source=test#top"]) { + assert.equal(normalizePath(path), "/"); + } + for (const path of ["docs/api/js", "/docs/api/js/", "/docs/api/js?utm_source=test#LoroDoc", "/docs/api/js#LoroDoc"]) { + assert.equal(getPageUrls(path).canonicalUrl, "https://loro.dev/docs/api/js"); + } +}); + +test("only existing translations receive Chinese alternates", () => { + assert.equal(getPageUrls("/docs/api/js?ref=test#intro").chineseUrl, "https://cn.loro.dev/docs/api/js"); + for (const path of ["/blog/crdt-is-not-enough", "/blog/mergeable-containers", "/changelog/v1.9.0", "/docs/advanced/jsonpath", "/new-untranslated-page"]) { + assert.equal(hasChineseTranslation(path), false, path); + assert.equal(getPageUrls(path).chineseUrl, undefined, path); + } + assert.equal(chineseRoutes.routes.length, new Set(chineseRoutes.routes).size); + assert.ok(chineseRoutes.routes.every((path) => normalizePath(path) === path)); + assert.match(chineseRoutes.commit, /^[a-f0-9]{40}$/); +}); + +test("API helpers are outside the pages router and excluded from sitemaps", () => { + for (const [oldName, component] of [["indent", "Indent"], ["method", "Method"]]) { + assert.equal(existsSync(new URL(`pages/docs/api/${oldName}.jsx`, root)), false); + assert.ok(existsSync(new URL(`components/api-reference/${component}.jsx`, root))); + assert.ok(read("next-sitemap.config.js").includes(`/docs/api/${oldName}`)); + assert.ok(!read("public/sitemap-0.xml").includes(`/docs/api/${oldName}<`)); + assert.ok(!chineseRoutes.routes.includes(`/docs/api/${oldName}`)); + } + const mdx = read("pages/docs/api/js.mdx"); + for (const match of mdx.matchAll(/import\s+(?:styles|Indent|Method)\s+from\s+"([^"]+)"/g)) { + const imported = resolve(new URL("pages/docs/api/", root).pathname, match[1]); + assert.ok(existsSync(imported) || existsSync(`${imported}.jsx`), match[1]); + } +}); + +test("content does not contain nested URL schemes or punctuation inside known links", () => { + const files = readdirSync(new URL("pages/", root), { recursive: true }) + .filter((path) => /\.(md|mdx)$/.test(path)); + for (const file of files) { + const source = read(`pages/${file}`); + assert.doesNotMatch(source, /https?:\/\/(?:github|twitter)\.com\/https?:\/\//, file); + assert.doesNotMatch(source, /https:\/\/loro\.dev\/blog\/loro-richtext(?:。|%E3%80%82)/i, file); + } +}); diff --git a/scripts/update-chinese-routes.mjs b/scripts/update-chinese-routes.mjs new file mode 100644 index 0000000..9c16be0 --- /dev/null +++ b/scripts/update-chinese-routes.mjs @@ -0,0 +1,26 @@ +import { execFileSync } from "node:child_process"; +import { readdirSync, writeFileSync } from "node:fs"; +import { resolve, relative, sep } from "node:path"; + +// Run after publishing translations: node scripts/update-chinese-routes.mjs ../loro-docs-zh +// Read a local checkout so building the site never needs a network request. +const source = process.argv[2]; +if (!source) { + throw new Error("Pass the path to a loro-dev/loro-docs-zh checkout."); +} +const checkout = resolve(source); +const pages = resolve(checkout, "pages"); +const routes = readdirSync(pages, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.(md|mdx)$/.test(entry.name)) + .map((entry) => relative(pages, resolve(entry.parentPath ?? entry.path, entry.name)).split(sep).join("/")) + .filter((path) => !path.split("/").some((part) => part.startsWith("_"))) + .map((path) => `/${path.replace(/\.(md|mdx)$/, "").replace(/(^|\/)index$/, "")}`.replace(/\/$/, "") || "/") + .sort(); +const manifest = { + repository: "https://github.com/loro-dev/loro-docs-zh", + commit: execFileSync("git", ["-C", checkout, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(), + routes, +}; +writeFileSync(new URL("../lib/chinese-routes.mjs", import.meta.url), + `// Chinese pages are maintained in a separate repository. Only advertise known translations.\n// Refresh with scripts/update-chinese-routes.mjs after those pages are published.\nexport default ${JSON.stringify(manifest, null, 2)};\n`); +console.log(`Recorded ${routes.length} Chinese routes at ${manifest.commit}.`); diff --git a/theme.config.jsx b/theme.config.jsx index 8bb75c1..b1613bb 100644 --- a/theme.config.jsx +++ b/theme.config.jsx @@ -4,6 +4,7 @@ import { useConfig } from "nextra-theme-docs"; import Image from "next/image"; import Footer from "./components/landing/Footer"; import LanguageDropdown from "./components/LanguageDropdown"; +import { getPageUrls } from "./lib/seo.mjs"; const DEFAULT_TITLE = "Loro – Reimagine state management with CRDTs"; const DEFAULT_DESCRIPTION = @@ -57,15 +58,7 @@ export default { head: () => { const config = useConfig(); const { asPath } = useRouter(); - const rawPath = - !asPath || asPath === "/" - ? "/" - : asPath.startsWith("/") - ? asPath - : `/${asPath}`; - const normalizedPath = rawPath.split("#")[0] || "/"; - const canonicalUrl = `https://loro.dev${normalizedPath}`; - const chineseUrl = `https://cn.loro.dev${normalizedPath}`; + const { pathname: normalizedPath, canonicalUrl, chineseUrl } = getPageUrls(asPath); // Nextra v3 moves reserved fields like `title`, `description`, `image` // out of `frontMatter` into top-level config. Fallback to frontMatter for // older content that still sets them there. @@ -74,7 +67,7 @@ export default { config.description ?? config.frontMatter?.description ?? DEFAULT_DESCRIPTION; const metaImage = config.image ?? config.frontMatter?.image; const pageTitle = formatPageTitle(metaTitle); - const ogImage = metaImage || DEFAULT_IMAGE; + const ogImage = new URL(metaImage || DEFAULT_IMAGE, "https://loro.dev").href; const ogType = normalizedPath.startsWith("/blog/") ? "article" : "website"; @@ -107,7 +100,7 @@ export default { <meta name="apple-mobile-web-app-title" content="Loro" /> <link rel="canonical" href={canonicalUrl} /> <link rel="alternate" hrefLang="en" href={canonicalUrl} /> - <link rel="alternate" hrefLang="zh" href={chineseUrl} /> + {chineseUrl && <link rel="alternate" hrefLang="zh" href={chineseUrl} />} <link rel="alternate" hrefLang="x-default" href={canonicalUrl} /> </> );