docs: add package introductions and the @robonen/crdt guide
An intro.vue landing for all 12 packages, plus a multi-section crdt guide (Concepts, Primitives, Replication & Sync, and an interactive convergence Playground).
This commit is contained in:
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const usageExample = `import { compose, base, typescript, vue, vitest, imports } from '@robonen/eslint';
|
||||||
|
|
||||||
|
// eslint.config.ts
|
||||||
|
export default compose(base, typescript, vue, vitest, imports);`;
|
||||||
|
|
||||||
|
const overrideExample = `import { compose, base, typescript } from '@robonen/eslint';
|
||||||
|
|
||||||
|
export default compose(base, typescript, {
|
||||||
|
// later entries override earlier ones
|
||||||
|
rules: { 'no-console': 'off' },
|
||||||
|
});`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>@robonen/eslint</h1>
|
||||||
|
<p class="text-lg text-(--fg-muted)">
|
||||||
|
Composable ESLint flat-config presets — assemble a linting setup from
|
||||||
|
small, focused building blocks instead of one monolithic config.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Modern ESLint flat config is just an ordered array of config objects, but
|
||||||
|
wiring up plugins, parsers, and rule sets by hand is repetitive and easy
|
||||||
|
to get wrong. <code>@robonen/eslint</code> ships a curated set of presets
|
||||||
|
— <code>base</code>, <code>typescript</code>, <code>vue</code>,
|
||||||
|
<code>vitest</code>, <code>imports</code>, <code>node</code>,
|
||||||
|
<code>regexp</code>, and <code>stylistic</code> — and a single
|
||||||
|
<code>compose()</code> helper that flattens them into one config array.
|
||||||
|
Pick the presets your project needs, layer your own overrides on top, and
|
||||||
|
you have a consistent, type-safe lint setup in a few lines.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">Composable presets</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
Mix and match focused presets per language and tool. Each preset is a
|
||||||
|
plain flat-config array — no magic, no hidden state.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">Override-friendly</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
Append inline config objects after presets. Later entries win, exactly
|
||||||
|
as ESLint flat-config semantics intend.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">Conditional by design</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
<code>compose()</code> skips <code>false</code>/<code>null</code>/<code>undefined</code>
|
||||||
|
entries, so feature flags and conditional spreads just work.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">Typed flat config</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
Exported <code>FlatConfig</code> and <code>Rules</code> types give you
|
||||||
|
editor autocomplete and type-checked overrides in
|
||||||
|
<code>eslint.config.ts</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Install</h2>
|
||||||
|
<p>
|
||||||
|
Install the package alongside ESLint and <code>jiti</code> (so ESLint can
|
||||||
|
load a TypeScript <code>eslint.config.ts</code>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="`pnpm add -D @robonen/eslint eslint jiti`" lang="bash" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Usage</h2>
|
||||||
|
<p>
|
||||||
|
Create <code>eslint.config.ts</code> in your project root and compose the
|
||||||
|
presets you want:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="usageExample" lang="ts" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Add inline config objects after the presets to tweak rules — later
|
||||||
|
entries override earlier ones:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="overrideExample" lang="ts" />
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-2">Where to next</h3>
|
||||||
|
<ul class="text-sm text-(--fg-muted) space-y-1.5 list-disc pl-5 m-0">
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/eslint/overview" class="text-(--accent-text) hover:underline">compose</NuxtLink>
|
||||||
|
— flatten presets and overrides into one config array.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/eslint/base" class="text-(--accent-text) hover:underline">base</NuxtLink>
|
||||||
|
— core ESLint, unicorn, regexp rules and global ignores.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/eslint/typescript" class="text-(--accent-text) hover:underline">typescript</NuxtLink>
|
||||||
|
and
|
||||||
|
<NuxtLink to="/eslint/vue" class="text-(--accent-text) hover:underline">vue</NuxtLink>
|
||||||
|
— language presets for TS and Vue 3 SFCs.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Read the guide sections below for the full preset table and migration
|
||||||
|
notes from <code>@robonen/oxlint</code>.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const nodeExample = `// Node / isomorphic library
|
||||||
|
{ "extends": "@robonen/tsconfig/tsconfig.base.json" }`;
|
||||||
|
|
||||||
|
const vueExample = `// Vue package, with path aliases
|
||||||
|
{
|
||||||
|
"extends": "@robonen/tsconfig/tsconfig.vue.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const splitExample = `// tsconfig.json — solution root
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.src.json" }, // extends tsconfig.dom.json
|
||||||
|
{ "path": "./tsconfig.node.json" } // *.config.ts, types: ["node"]
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>@robonen/tsconfig</h1>
|
||||||
|
<p class="text-lg text-(--fg-muted)">
|
||||||
|
Shared, strict TypeScript configurations — a small set of layered
|
||||||
|
presets you extend instead of copying compiler options between packages.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Every package in a monorepo wants the same modern, strict TypeScript
|
||||||
|
baseline, but keeping a dozen <code>tsconfig.json</code> files in sync by
|
||||||
|
hand drifts almost immediately. <code>@robonen/tsconfig</code> ships one
|
||||||
|
carefully tuned <code>base</code> config and three environment layers
|
||||||
|
— <code>dom</code>, <code>vue</code>, and <code>node</code> — that extend
|
||||||
|
it. Point your package's <code>extends</code> at the right preset and you
|
||||||
|
inherit a consistent, bundler-first, type-check-only setup with no local
|
||||||
|
compiler options to maintain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">Layered presets</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
<code>base</code> → <code>dom</code> → <code>vue</code>, plus
|
||||||
|
a sibling <code>node</code> layer. Extend the one that matches the
|
||||||
|
environment; everything else is inherited.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">Strict by default</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
<code>strict</code> plus <code>noUncheckedIndexedAccess</code>,
|
||||||
|
<code>noImplicitOverride</code>, <code>noImplicitReturns</code> and
|
||||||
|
<code>noFallthroughCasesInSwitch</code> are on out of the box.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">Bundler-first</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
<code>module: Preserve</code> with <code>Bundler</code> resolution,
|
||||||
|
<code>verbatimModuleSyntax</code> and <code>isolatedModules</code>.
|
||||||
|
Emit is <code>noEmit</code> — declarations come from
|
||||||
|
<code>tsdown</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">Env isolation</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
Browser <code>src</code> (DOM, no Node globals) and tooling files
|
||||||
|
(<code>node</code> types, no DOM) split into separate projects wired
|
||||||
|
with project references.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Install</h2>
|
||||||
|
<p>
|
||||||
|
Add the package as a dev dependency. It ships only JSON presets — no
|
||||||
|
runtime code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="`pnpm add -D @robonen/tsconfig`" lang="bash" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Usage</h2>
|
||||||
|
<p>
|
||||||
|
Pick the preset that matches the package and extend it from your
|
||||||
|
<code>tsconfig.json</code>:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="nodeExample" lang="json" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Vue SFC packages extend the <code>vue</code> layer (adds
|
||||||
|
<code>jsx: preserve</code> and strict
|
||||||
|
<code>vueCompilerOptions</code>) and can declare path aliases inline:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="vueExample" lang="json" />
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-4">
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
<strong class="text-(--fg)">Note:</strong> path aliases resolve relative
|
||||||
|
to the <code class="text-(--fg)">tsconfig.json</code> location —
|
||||||
|
<code class="text-(--fg)">baseUrl</code> is intentionally omitted
|
||||||
|
(deprecated, removed in TypeScript 7.0).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>DOM + Node split</h2>
|
||||||
|
<p>
|
||||||
|
Most packages mix browser <code>src</code> with Node tooling files
|
||||||
|
(<code>vite.config.ts</code>, <code>vitest.config.ts</code>,
|
||||||
|
<code>tsdown.config.ts</code>). Split them into two projects wired with
|
||||||
|
references so <code>src</code> never sees Node globals and config files
|
||||||
|
never see <code>DOM</code>, then type-check the whole package with
|
||||||
|
<code>tsc -b</code> / <code>vue-tsc -b</code>:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="splitExample" lang="json" />
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-2">Where to next</h3>
|
||||||
|
<ul class="text-sm text-(--fg-muted) space-y-1.5 list-disc pl-5 m-0">
|
||||||
|
<li>
|
||||||
|
<strong class="text-(--fg)">Presets</strong> — the full table of
|
||||||
|
<code>base</code>, <code>dom</code>, <code>vue</code> and
|
||||||
|
<code>node</code>, with what each layer adds.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong class="text-(--fg)">Project references</strong> — the complete
|
||||||
|
DOM + Node split with <code>composite</code> and
|
||||||
|
<code>tsBuildInfoFile</code> wiring.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong class="text-(--fg)">What's included</strong> — the exact
|
||||||
|
compiler options the <code>base</code> preset turns on.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
See the guide sections in the sidebar for each of the above.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const usageExample = `import { defineConfig } from 'tsdown';
|
||||||
|
import { sharedConfig } from '@robonen/tsdown';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
...sharedConfig,
|
||||||
|
tsconfig: './tsconfig.src.json',
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
});`;
|
||||||
|
|
||||||
|
const overrideExample = `import { defineConfig } from 'tsdown';
|
||||||
|
import { sharedConfig } from '@robonen/tsdown';
|
||||||
|
import Vue from 'unplugin-vue/rolldown';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
...sharedConfig,
|
||||||
|
entry: ['src/index.ts', 'src/*/index.ts'],
|
||||||
|
plugins: [Vue({ isProduction: true })],
|
||||||
|
// layer on top of the shared defaults
|
||||||
|
dts: { vue: true },
|
||||||
|
});`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>@robonen/tsdown</h1>
|
||||||
|
<p class="text-lg text-(--fg-muted)">
|
||||||
|
Shared tsdown build configuration for every <code>@robonen</code>
|
||||||
|
package — one source of truth for output formats, declarations, and
|
||||||
|
bundle hygiene.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Every library in this monorepo ships dual ESM/CJS builds with type
|
||||||
|
declarations, a clean <code>dist</code>, and a consistent license
|
||||||
|
banner. Re-declaring that in each package's
|
||||||
|
<code>tsdown.config.ts</code> is repetitive and drifts over time.
|
||||||
|
<code>@robonen/tsdown</code> exports a single
|
||||||
|
<code>sharedConfig</code> object you spread into
|
||||||
|
<code>defineConfig</code>, then add only what is package-specific —
|
||||||
|
usually just <code>entry</code> and <code>tsconfig</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">Dual ESM + CJS</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
Emits both <code>esm</code> and <code>cjs</code> formats so packages
|
||||||
|
work in modern bundlers and legacy <code>require</code> setups alike.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">Types included</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
<code>dts: true</code> generates <code>.d.ts</code> declarations on
|
||||||
|
every build — no separate type pipeline to maintain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">Clean, stable output</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
<code>clean: true</code> wipes <code>dist</code> first and
|
||||||
|
<code>hash: false</code> keeps file names deterministic for
|
||||||
|
predictable publishing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">Spread & override</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
It is a plain object typed as <code>InlineConfig</code> — spread it,
|
||||||
|
override any field, and let editor autocomplete guide you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Install</h2>
|
||||||
|
<p>
|
||||||
|
Add the config package and <code>tsdown</code> itself as dev
|
||||||
|
dependencies:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="`pnpm add -D @robonen/tsdown tsdown`" lang="bash" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Usage</h2>
|
||||||
|
<p>
|
||||||
|
Create <code>tsdown.config.ts</code> in your package, spread
|
||||||
|
<code>sharedConfig</code>, and supply your entry points:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="usageExample" lang="ts" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Because <code>sharedConfig</code> is a normal object, you can override
|
||||||
|
or extend any field after spreading — add plugins, extra entries, or
|
||||||
|
tweak the declaration options:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="overrideExample" lang="ts" />
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-2">Where to next</h3>
|
||||||
|
<ul class="text-sm text-(--fg-muted) space-y-1.5 list-disc pl-5 m-0">
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/tsdown/overview" class="text-(--accent-text) hover:underline">sharedConfig</NuxtLink>
|
||||||
|
— the full list of defaults and their exact values.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Read the guide sections below for the conventions baked into the
|
||||||
|
shared build and tips for package-specific overrides.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
<!-- title: Concepts -->
|
||||||
|
<!-- order: 1 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
const opIdSrc = `import { opId, opIdEq, opIdToString, createSiteId } from '@robonen/crdt';
|
||||||
|
|
||||||
|
// An OpId is just { site, clock } — a per-site Lamport counter
|
||||||
|
// tagged with the site that produced it.
|
||||||
|
const id = opId('alice', 3); // { site: 'alice', clock: 3 }
|
||||||
|
|
||||||
|
opIdToString(id); // 'alice@3'
|
||||||
|
opIdEq(id, opId('alice', 3)); // true
|
||||||
|
|
||||||
|
// A site id is a per-replica handle. Generate one when a session starts.
|
||||||
|
const site = createSiteId(); // e.g. 'k3f9a2d1xz'`;
|
||||||
|
|
||||||
|
const lamportSrc = `import { LamportClock } from '@robonen/crdt';
|
||||||
|
|
||||||
|
const clock = new LamportClock('alice');
|
||||||
|
|
||||||
|
clock.tick(); // { site: 'alice', clock: 1 }
|
||||||
|
clock.tick(); // { site: 'alice', clock: 2 }
|
||||||
|
|
||||||
|
// We hear about a remote op from 'bob' at clock 5.
|
||||||
|
clock.observe({ site: 'bob', clock: 5 });
|
||||||
|
|
||||||
|
// Our next local id jumps past it, so it's causally *after* what we've seen.
|
||||||
|
clock.tick(); // { site: 'alice', clock: 6 }`;
|
||||||
|
|
||||||
|
const compareSrc = `import { compareOpId, opId } from '@robonen/crdt';
|
||||||
|
|
||||||
|
// Higher clock wins.
|
||||||
|
compareOpId(opId('alice', 1), opId('alice', 2)); // < 0 (2 is greater)
|
||||||
|
|
||||||
|
// Equal clocks → site id breaks the tie, deterministically.
|
||||||
|
compareOpId(opId('alice', 2), opId('bob', 2)); // < 0 ('alice' < 'bob')
|
||||||
|
compareOpId(opId('bob', 2), opId('alice', 2)); // > 0
|
||||||
|
|
||||||
|
// Identical ids compare equal.
|
||||||
|
compareOpId(opId('alice', 2), opId('alice', 2)); // 0`;
|
||||||
|
|
||||||
|
const vvSrc = `import { VersionVector, opId } from '@robonen/crdt';
|
||||||
|
|
||||||
|
const vv = new VersionVector();
|
||||||
|
vv.observe(opId('alice', 3));
|
||||||
|
vv.observe(opId('bob', 1));
|
||||||
|
|
||||||
|
// "Have I already seen this op?" — the basis for dedup.
|
||||||
|
vv.has(opId('alice', 2)); // true (we've seen alice up to 3)
|
||||||
|
vv.has(opId('alice', 3)); // true
|
||||||
|
vv.has(opId('alice', 4)); // false (not yet)
|
||||||
|
vv.has(opId('carol', 1)); // false (never heard from carol)
|
||||||
|
|
||||||
|
// Highest dense clock per site (0 if a site is unknown).
|
||||||
|
vv.get('alice'); // 3
|
||||||
|
vv.get('carol'); // 0`;
|
||||||
|
|
||||||
|
const vvWireSrc = `import { VersionVector, opId } from '@robonen/crdt';
|
||||||
|
|
||||||
|
const local = new VersionVector();
|
||||||
|
local.observe(opId('alice', 5));
|
||||||
|
local.observe(opId('bob', 2));
|
||||||
|
|
||||||
|
// Snapshot for transport: a plain { site: clock } object.
|
||||||
|
const snapshot = local.toJSON(); // { alice: 5, bob: 2 }
|
||||||
|
|
||||||
|
// The other side reconstructs it and compares against its own log
|
||||||
|
// to compute exactly which ops you're missing.
|
||||||
|
const remoteKnows = VersionVector.fromJSON(snapshot);
|
||||||
|
remoteKnows.has(opId('alice', 4)); // true → skip it
|
||||||
|
remoteKnows.has(opId('alice', 6)); // false → send it`;
|
||||||
|
|
||||||
|
const propsSrc = `// Commutative — order of application doesn't matter:
|
||||||
|
// apply(apply(s, x), y) === apply(apply(s, y), x)
|
||||||
|
//
|
||||||
|
// Idempotent — re-applying a seen op is a no-op:
|
||||||
|
// apply(s, x) === apply(apply(s, x), x)
|
||||||
|
//
|
||||||
|
// Convergent — same op SET ⇒ same state, regardless of how it got there.
|
||||||
|
//
|
||||||
|
// These three together mean a network that reorders, duplicates, and
|
||||||
|
// delays messages can never push two replicas to different states.`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>Concepts</h1>
|
||||||
|
<p>
|
||||||
|
Every primitive in <code>@robonen/crdt</code> rests on one small idea: if all replicas agree on a
|
||||||
|
<strong>deterministic total order</strong> over operations, then applying the same set of operations
|
||||||
|
— in any order, with duplicates, after any delay — always produces the same state. This page builds
|
||||||
|
that mental model from the ground up: sites and replicas, Lamport clocks and op ids, the single
|
||||||
|
tie-break that resolves every conflict, version vectors for deduplication and deltas, and the three
|
||||||
|
algebraic properties that make convergence inevitable rather than hopeful.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Replicas and sites</h2>
|
||||||
|
<p>
|
||||||
|
A <strong>replica</strong> is one copy of the shared state — a browser tab, a mobile app, a server
|
||||||
|
process. Each replica is owned by exactly one <strong>site</strong>, identified by a
|
||||||
|
<code>SiteId</code> (just a string). The site id is the thing that makes one replica distinguishable
|
||||||
|
from every other, so it must be unique across all participants. Use <code>createSiteId</code> to mint
|
||||||
|
one when a session begins; it trades on randomness for uniqueness, not secrecy, so there's no crypto
|
||||||
|
dependency.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Replicas never share mutable memory. They evolve independently and communicate only by exchanging
|
||||||
|
<strong>operations</strong> — small, self-describing facts like "insert this character" or "set this
|
||||||
|
key". The whole job of a CRDT is to make sure that once two replicas have seen the same operations,
|
||||||
|
they hold the same state, no matter what the network did to the messages in between.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Op ids: naming every operation</h2>
|
||||||
|
<p>
|
||||||
|
For replicas to talk about the same operation — to deduplicate it, to refer to it as a causal
|
||||||
|
dependency, to break ties against it — every operation needs a stable, globally unique name. That
|
||||||
|
name is an <code>OpId</code>: a per-site counter (its Lamport <code>clock</code>) tagged with the
|
||||||
|
<code>site</code> that produced it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="opIdSrc" lang="ts" />
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Because the counter is local to a site and the id carries that site, two replicas can generate ids
|
||||||
|
completely independently and never collide. There's no coordination, no central allocator, no UUID
|
||||||
|
round-trips — uniqueness falls out of the structure. <code>opIdToString</code> gives the canonical
|
||||||
|
<code>site@clock</code> form, handy as a map key or for logging.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Lamport clocks: encoding causality</h2>
|
||||||
|
<p>
|
||||||
|
A bare per-site counter is unique, but it isn't enough to compare two operations from different
|
||||||
|
sites in a meaningful way. <code>LamportClock</code> fixes that. It hands out monotonically
|
||||||
|
increasing ids via <code>tick()</code>, and — crucially — it <code>observe()</code>s the clocks of
|
||||||
|
remote operations it learns about, jumping its own counter ahead so that anything it produces next is
|
||||||
|
numbered <em>after</em> what it has already seen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="lamportSrc" lang="ts" />
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
This is the Lamport <em>happens-before</em> rule in miniature: if operation
|
||||||
|
<strong>A</strong> causally precedes <strong>B</strong> (B was generated by a replica that had
|
||||||
|
already seen A), then A's clock is strictly less than B's. The converse isn't guaranteed — two ops
|
||||||
|
with unrelated clocks may simply be <strong>concurrent</strong>, produced by replicas that hadn't yet
|
||||||
|
heard from each other. That's fine, and expected: concurrency is exactly the situation a CRDT exists
|
||||||
|
to resolve.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>compareOpId: the one tie-break</h2>
|
||||||
|
<p>
|
||||||
|
Lamport clocks give a <em>partial</em> order — they leave concurrent operations incomparable. But to
|
||||||
|
converge, every replica must agree on a single <strong>total</strong> order so that any two
|
||||||
|
operations can be ranked the same way everywhere. <code>compareOpId</code> is that total order, and it
|
||||||
|
is the only conflict-resolution rule in the entire library:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Higher clock wins.</strong> A later operation supersedes an earlier one.</li>
|
||||||
|
<li>
|
||||||
|
<strong>Site id breaks ties.</strong> When two ops share a clock (they were concurrent), the
|
||||||
|
string comparison of their site ids picks a winner — arbitrary, but identical on every replica.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="compareSrc" lang="ts" />
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
That second rule is the quiet hero of the whole design. The choice of winner doesn't matter; what
|
||||||
|
matters is that <em>every replica makes the same choice</em>. Because site ids are unique and string
|
||||||
|
comparison is deterministic, two replicas resolving the same concurrent edit will always pick the
|
||||||
|
same survivor. That single shared decision is what lets a last-writer-wins register and a sequence
|
||||||
|
CRDT, built by different code, nonetheless agree on the final document.
|
||||||
|
</p>
|
||||||
|
<div class="my-4 rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
|
||||||
|
<p class="m-0 text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
<strong class="text-(--fg)">Why one rule for everything?</strong>
|
||||||
|
<code class="text-(--accent-text)">LwwRegister</code> uses
|
||||||
|
<code class="text-(--accent-text)">compareOpId</code> to pick the surviving value;
|
||||||
|
<code class="text-(--accent-text)">Rga</code> uses it to break ties between concurrent inserts at
|
||||||
|
the same position; <code class="text-(--accent-text)">MarkStore</code> uses it to decide which
|
||||||
|
formatting wins per character. One total order, applied consistently, is what turns a pile of
|
||||||
|
independent primitives into a coherent, converging system.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Version vectors: who has seen what</h2>
|
||||||
|
<p>
|
||||||
|
Op ids order operations; a <code>VersionVector</code> summarizes <em>which</em> operations a replica
|
||||||
|
has seen. It maps each known site to the highest clock observed from it. Its power comes from one
|
||||||
|
assumption: per-site clocks are <strong>dense</strong> — a site emits <code>1, 2, 3, …</code> with no
|
||||||
|
gaps. Given that, "highest clock seen from site X" implies "every op from X up to that clock has been
|
||||||
|
seen", so a single integer per site captures the entire causal history.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="vvSrc" lang="ts" />
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h3>Deduplication</h3>
|
||||||
|
<p>
|
||||||
|
Networks redeliver. Because operations are idempotent (more on that below), re-applying one is
|
||||||
|
harmless — but <code>vv.has(id)</code> lets you skip the work entirely. If the vector already covers
|
||||||
|
an op's site and clock, you've seen it; drop it before it ever touches your state. This is the first
|
||||||
|
line of defense that keeps duplicate messages from doing anything observable.
|
||||||
|
</p>
|
||||||
|
<h3>Deltas</h3>
|
||||||
|
<p>
|
||||||
|
The same vector drives efficient sync. When a peer tells you its version vector, you compare it
|
||||||
|
against your own op log and send back <em>only</em> the operations it's missing — never the whole
|
||||||
|
document. A site with clock <code>4</code> in their vector but <code>9</code> in yours means ops
|
||||||
|
<code>5</code> through <code>9</code> are the delta. Version vectors are tiny and serialize to a plain
|
||||||
|
<code>{ site: clock }</code> object, so they're cheap to ship as the "here's what I have" handshake.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="vvWireSrc" lang="ts" />
|
||||||
|
<div class="prose-docs">
|
||||||
|
<div class="my-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
|
||||||
|
<p class="m-0 text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
<strong class="text-amber-700 dark:text-amber-400">Density matters.</strong>
|
||||||
|
<code class="text-(--accent-text)">VersionVector</code> only works because clocks arrive without
|
||||||
|
gaps. If you generate ids with a raw <code class="text-(--accent-text)">LamportClock</code>, deliver
|
||||||
|
them in order per site (the <code class="text-(--accent-text)">Replica</code>'s causal buffer does
|
||||||
|
this for you) so a single high-water mark per site can stand in for the full set of seen ops.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>The three properties</h2>
|
||||||
|
<p>
|
||||||
|
Everything above exists to guarantee three algebraic properties of operations. They're the formal
|
||||||
|
promise behind "it just converges", and they're verified by property tests across the package.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="propsSrc" lang="ts" />
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Commutative</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
Order of application doesn't change the result. A replica can integrate operations as they arrive,
|
||||||
|
in whatever sequence the network delivers them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Idempotent</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
Applying the same operation twice is the same as applying it once. Redelivery and retries are safe;
|
||||||
|
version vectors make them free.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Convergent</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
Same set of operations, same final state — full stop. Two replicas that have seen the same ops are
|
||||||
|
byte-for-byte identical.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Commutativity and idempotency are <em>local</em> properties of how a single replica integrates an
|
||||||
|
operation. Convergence is the <em>global</em> consequence: if integration is both order-independent
|
||||||
|
and duplicate-safe, then the state of a replica is a pure function of the <em>set</em> of operations
|
||||||
|
it has seen, with no dependence on path or timing. That's why a CRDT tolerates the worst a network
|
||||||
|
can do — reordering, duplication, partition, arbitrary delay — and still lands every participant on
|
||||||
|
the same document.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Putting it together</h2>
|
||||||
|
<p>
|
||||||
|
With the model in hand, the rest of the library reads as direct applications of it. The same
|
||||||
|
<code>OpId</code> that names an operation is the value <code>compareOpId</code> ranks; the same
|
||||||
|
Lamport clock that produced it advances when you observe a peer; the same dense clocks that make ids
|
||||||
|
unique make version vectors a one-integer-per-site summary. From here:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/primitives">Primitives</NuxtLink> — see the order in action across
|
||||||
|
<NuxtLink to="/crdt/rga">Rga</NuxtLink>, <NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink>,
|
||||||
|
and fractional indexing with <NuxtLink to="/crdt/key-between">keyBetween</NuxtLink>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/replication">Replication & Sync</NuxtLink> — how
|
||||||
|
<NuxtLink to="/crdt/replica">Replica</NuxtLink> wires a clock, op log, and causal buffer into
|
||||||
|
version-vector deltas.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/playground">Playground</NuxtLink> — watch two replicas diverge and reconcile,
|
||||||
|
live in the browser.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
<!-- title: Primitives -->
|
||||||
|
<!-- order: 2 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
const lwwRegister = `import { LwwRegister, opId } from '@robonen/crdt';
|
||||||
|
|
||||||
|
// Two replicas hold the same register, start from the same value.
|
||||||
|
const a = new LwwRegister('draft');
|
||||||
|
const b = new LwwRegister('draft');
|
||||||
|
|
||||||
|
// They write concurrently — A at clock 4, B at clock 5.
|
||||||
|
a.set('A wins?', opId('a', 4));
|
||||||
|
b.set('B wins!', opId('b', 5));
|
||||||
|
|
||||||
|
// Exchange the writes (order and duplicates don't matter):
|
||||||
|
a.set('B wins!', opId('b', 5)); // 5 > 4 → accepted, returns true
|
||||||
|
b.set('A wins?', opId('a', 4)); // 4 < 5 → rejected, returns false
|
||||||
|
|
||||||
|
a.get(); // 'B wins!'
|
||||||
|
a.get() === b.get(); // true — converged on the higher op id`;
|
||||||
|
|
||||||
|
const lwwMap = `import { LwwMap, opId } from '@robonen/crdt';
|
||||||
|
|
||||||
|
const a = new LwwMap<string, string>();
|
||||||
|
const b = new LwwMap<string, string>();
|
||||||
|
|
||||||
|
// Concurrent edits to the same key, plus a concurrent delete.
|
||||||
|
a.set('color', 'red', opId('a', 7));
|
||||||
|
b.set('color', 'blue', opId('b', 7)); // same clock — site id breaks the tie
|
||||||
|
|
||||||
|
a.delete('color', opId('a', 7)); // tie too: delete vs set at the same id
|
||||||
|
|
||||||
|
// After both replicas see all three ops…
|
||||||
|
b.set('color', 'red', opId('a', 7)); // already covered by b's clock-7 write
|
||||||
|
a.set('color', 'blue', opId('b', 7)); // 'b' > 'a' at clock 7 → blue wins
|
||||||
|
|
||||||
|
a.get('color'); // 'blue'
|
||||||
|
a.has('color'); // true
|
||||||
|
a.toEntries(); // [['color', 'blue']]`;
|
||||||
|
|
||||||
|
const fractionalBetween = `import { keyBetween } from '@robonen/crdt';
|
||||||
|
|
||||||
|
// Open bounds (null) ask for "before everything" / "after everything".
|
||||||
|
const first = keyBetween(null, null); // e.g. 'V'
|
||||||
|
const second = keyBetween(first, null); // a key after 'first'
|
||||||
|
const zeroth = keyBetween(null, first); // a key before 'first'
|
||||||
|
|
||||||
|
// Insert strictly between two existing neighbors — no renumbering, ever.
|
||||||
|
const mid = keyBetween(zeroth, first);
|
||||||
|
zeroth < mid && mid < first; // true
|
||||||
|
|
||||||
|
// Sorting items by key reproduces their order:
|
||||||
|
const items = [
|
||||||
|
{ text: 'b', key: first },
|
||||||
|
{ text: 'a', key: zeroth },
|
||||||
|
{ text: 'ab', key: mid },
|
||||||
|
];
|
||||||
|
items.sort((x, y) => (x.key < y.key ? -1 : x.key > y.key ? 1 : 0));
|
||||||
|
items.map(i => i.text); // ['a', 'ab', 'b']`;
|
||||||
|
|
||||||
|
const fractionalBatch = `import { keysBetween } from '@robonen/crdt';
|
||||||
|
|
||||||
|
// Pre-allocate N keys at once — ascending, all strictly between the bounds.
|
||||||
|
const keys = keysBetween(null, null, 5);
|
||||||
|
// each keys[i] < keys[i + 1]
|
||||||
|
|
||||||
|
// Moving an item is a single-field write: give it a new key between its
|
||||||
|
// new neighbors and re-sort. Nothing else in the list changes.
|
||||||
|
function move(list, fromKeyLeft, fromKeyRight) {
|
||||||
|
return keyBetween(fromKeyLeft, fromKeyRight);
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const rgaBasic = `import { Rga, opId } from '@robonen/crdt';
|
||||||
|
|
||||||
|
const rga = new Rga<string>();
|
||||||
|
|
||||||
|
// integrateInsert(id, value, originLeft) — originLeft = null means "at the start".
|
||||||
|
rga.integrateInsert(opId('a', 1), 'H', null);
|
||||||
|
rga.integrateInsert(opId('a', 2), 'i', opId('a', 1)); // after 'H'
|
||||||
|
|
||||||
|
rga.toArray().join(''); // 'Hi'
|
||||||
|
|
||||||
|
// Delete = tombstone. The node stays as an anchor; it just stops being visible.
|
||||||
|
rga.integrateDelete(opId('a', 2));
|
||||||
|
rga.toArray().join(''); // 'H'
|
||||||
|
rga.length; // 1 (visible count, tombstones excluded)`;
|
||||||
|
|
||||||
|
const rgaConverge = `import { Rga, opId } from '@robonen/crdt';
|
||||||
|
|
||||||
|
// Two replicas both start from "AC" and concurrently insert after 'A'.
|
||||||
|
function seed() {
|
||||||
|
const rga = new Rga<string>();
|
||||||
|
rga.integrateInsert(opId('seed', 1), 'A', null);
|
||||||
|
rga.integrateInsert(opId('seed', 2), 'C', opId('seed', 1));
|
||||||
|
return rga;
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = seed();
|
||||||
|
const right = seed();
|
||||||
|
|
||||||
|
// left inserts 'x' after 'A'; right inserts 'y' after 'A' — same origin.
|
||||||
|
const xOp = { id: opId('left', 5), value: 'x', origin: opId('seed', 1) };
|
||||||
|
const yOp = { id: opId('right', 5), value: 'y', origin: opId('seed', 1) };
|
||||||
|
|
||||||
|
// Apply locally, then exchange. Order and duplicates don't matter.
|
||||||
|
left.integrateInsert(xOp.id, xOp.value, xOp.origin);
|
||||||
|
left.integrateInsert(yOp.id, yOp.value, yOp.origin);
|
||||||
|
|
||||||
|
right.integrateInsert(yOp.id, yOp.value, yOp.origin);
|
||||||
|
right.integrateInsert(xOp.id, xOp.value, xOp.origin);
|
||||||
|
|
||||||
|
// Tie-break: higher op id first. opId('right', 5) > opId('left', 5)
|
||||||
|
// (same clock, 'right' > 'left'), so 'y' lands before 'x'.
|
||||||
|
left.toArray().join(''); // 'AyxC'
|
||||||
|
right.toArray().join(''); // 'AyxC' — converged`;
|
||||||
|
|
||||||
|
const rgaBuffer = `import { Rga, opId } from '@robonen/crdt';
|
||||||
|
|
||||||
|
const rga = new Rga<string>();
|
||||||
|
rga.integrateInsert(opId('a', 1), 'H', null);
|
||||||
|
|
||||||
|
// An op arrives BEFORE its origin (causal violation). integrateInsert
|
||||||
|
// returns false instead of corrupting order — the caller buffers it.
|
||||||
|
const pending = { id: opId('a', 3), value: '!', origin: opId('a', 2) };
|
||||||
|
const ok = rga.integrateInsert(pending.id, pending.value, pending.origin);
|
||||||
|
// ok === false — origin opId('a', 2) isn't present yet
|
||||||
|
|
||||||
|
// Once the missing origin lands, retry the buffered op:
|
||||||
|
rga.integrateInsert(opId('a', 2), 'i', opId('a', 1));
|
||||||
|
rga.integrateInsert(pending.id, pending.value, pending.origin); // now true
|
||||||
|
|
||||||
|
rga.toArray().join(''); // 'Hi!'`;
|
||||||
|
|
||||||
|
const marks = `import { Rga, MarkStore, opId } from '@robonen/crdt';
|
||||||
|
|
||||||
|
// Build a sequence and grab the op ids of its characters.
|
||||||
|
const rga = new Rga<string>();
|
||||||
|
const ids: ReturnType<typeof opId>[] = [];
|
||||||
|
let left: ReturnType<typeof opId> | null = null;
|
||||||
|
for (let i = 0; i < 'bold'.length; i++) {
|
||||||
|
const id = opId('a', i + 1);
|
||||||
|
rga.integrateInsert(id, 'bold'[i]!, left);
|
||||||
|
ids.push(id);
|
||||||
|
left = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const marks = new MarkStore();
|
||||||
|
|
||||||
|
// A span anchors to the FIRST and LAST character op ids (inclusive), not to
|
||||||
|
// integer offsets — so it survives concurrent inserts/deletes around it.
|
||||||
|
marks.add({
|
||||||
|
id: opId('a', 10),
|
||||||
|
type: 'strong',
|
||||||
|
value: true,
|
||||||
|
start: ids[0]!, // 'b'
|
||||||
|
end: ids[3]!, // 'd'
|
||||||
|
});
|
||||||
|
|
||||||
|
// resolve() returns one active type→value map per character, in document order.
|
||||||
|
const active = marks.resolve(rga.visible().map(n => n.id));
|
||||||
|
active.map(m => m.get('strong')); // [true, true, true, true]`;
|
||||||
|
|
||||||
|
const marksConflict = `import { MarkStore, opId } from '@robonen/crdt';
|
||||||
|
|
||||||
|
const store = new MarkStore();
|
||||||
|
const start = opId('a', 1);
|
||||||
|
const end = opId('a', 4);
|
||||||
|
|
||||||
|
// Concurrent formatting on the same range: B turns it bold, A clears it.
|
||||||
|
store.add({ id: opId('a', 9), type: 'strong', value: false, start, end });
|
||||||
|
store.add({ id: opId('b', 9), type: 'strong', value: true, start, end });
|
||||||
|
|
||||||
|
// Highest op id wins per (character, type). opId('b', 9) > opId('a', 9),
|
||||||
|
// so 'strong' resolves to true — a null/false value would have cleared it.
|
||||||
|
const order = [opId('a', 1), opId('a', 2), opId('a', 3), opId('a', 4)];
|
||||||
|
store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<!-- Intro -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>Primitives</h1>
|
||||||
|
<p>
|
||||||
|
<code>@robonen/crdt</code> is a small set of independent data structures, each convergent on
|
||||||
|
its own. You can use a single primitive in isolation — a last-writer-wins setting, an ordered
|
||||||
|
list, a collaborative string — or compose them into something bigger. This page walks through
|
||||||
|
each one with a construction example and a small converging scenario.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Every primitive leans on one shared idea:
|
||||||
|
<NuxtLink to="/crdt/compare-op-id">compareOpId</NuxtLink> — a deterministic total order over
|
||||||
|
operation ids (higher Lamport clock wins; site id breaks ties). Because all primitives resolve
|
||||||
|
conflicts the same way, two replicas that have seen the same operations always agree, no matter
|
||||||
|
the order or duplicates in which those operations arrived. If op ids are new to you, start with
|
||||||
|
<NuxtLink to="/crdt/concepts">Concepts</NuxtLink>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map of the package -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Registers</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
<code class="text-(--accent-text)">LwwRegister</code> and
|
||||||
|
<code class="text-(--accent-text)">LwwMap</code> — single values and keyed maps where the
|
||||||
|
write with the highest op id wins.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Ordering</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
<code class="text-(--accent-text)">keyBetween</code> /
|
||||||
|
<code class="text-(--accent-text)">keysBetween</code> — fractional indexing to place or move
|
||||||
|
an item with a single string key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Sequence</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
<code class="text-(--accent-text)">Rga</code> — a replicated growable array: an ordered
|
||||||
|
sequence CRDT with tombstones and a deterministic insert tie-break.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Marks</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
<code class="text-(--accent-text)">MarkStore</code> — lightweight Peritext formatting spans
|
||||||
|
anchored to character op ids, resolved per character by highest op id.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Registers -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>LWW registers</h2>
|
||||||
|
<p>
|
||||||
|
A <NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink> is the smallest CRDT: a single
|
||||||
|
value with a timestamp. Every write carries an <code>OpId</code>, and a write only takes effect
|
||||||
|
if its id is strictly later than the current one by <code>compareOpId</code>. That single rule
|
||||||
|
gives you the three convergence properties for free — applying writes is
|
||||||
|
<strong>commutative</strong> (a later write always beats an earlier one regardless of arrival
|
||||||
|
order), <strong>idempotent</strong> (re-applying a write is a no-op), and
|
||||||
|
<strong>convergent</strong> (every replica ends on the same winning write).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<code>set(value, id)</code> returns <code>true</code> when the write won and
|
||||||
|
<code>false</code> when it was superseded, which is handy for skipping downstream work.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="lwwRegister" lang="ts" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h3>LwwMap</h3>
|
||||||
|
<p>
|
||||||
|
<NuxtLink to="/crdt/lww-map">LwwMap</NuxtLink> is a register per key. Each entry tracks its own
|
||||||
|
timestamp and a tombstone flag, so concurrent <code>set</code> and <code>delete</code> on the
|
||||||
|
same key converge to whichever has the higher op id — deleting is just another timestamped
|
||||||
|
write that happens to hide the value. <code>get</code>, <code>has</code>, <code>keys</code>,
|
||||||
|
and <code>toEntries</code> all skip tombstoned entries, so the map reads like a plain map even
|
||||||
|
though deletions are retained internally for convergence.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="lwwMap" lang="ts" />
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
<strong class="text-(--fg)">Why keep tombstones?</strong> If a delete simply dropped the entry,
|
||||||
|
a concurrent <code class="text-(--accent-text)">set</code> arriving afterward would resurrect
|
||||||
|
the key — the two replicas would disagree on whether it exists. Retaining the delete as a
|
||||||
|
timestamped tombstone lets <code class="text-(--accent-text)">compareOpId</code> decide the
|
||||||
|
winner deterministically, the same way it does for live values.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ordering -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Fractional indexing</h2>
|
||||||
|
<p>
|
||||||
|
Ordering a collaborative list with integer indices is a trap: insert at position 2 and every
|
||||||
|
index after it shifts, so two replicas inserting concurrently clobber each other's positions.
|
||||||
|
<NuxtLink to="/crdt/key-between">keyBetween</NuxtLink> sidesteps this by giving each item a
|
||||||
|
<em>string key</em> that lives strictly between its neighbors. Order is recovered by sorting
|
||||||
|
keys with plain string comparison — the digit alphabet is ASCII-ascending, so lexical order
|
||||||
|
matches digit order.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Pass <code>null</code> for an open bound: <code>keyBetween(null, x)</code> is "before
|
||||||
|
<code>x</code>", <code>keyBetween(x, null)</code> is "after <code>x</code>", and
|
||||||
|
<code>keyBetween(null, null)</code> seeds an empty list. The result is always strictly between
|
||||||
|
the bounds, so there is unlimited room to keep subdividing — you never run out of space to
|
||||||
|
insert between two adjacent items.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="fractionalBetween" lang="ts" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h3>Batches and moves</h3>
|
||||||
|
<p>
|
||||||
|
<NuxtLink to="/crdt/keys-between">keysBetween</NuxtLink> generates <code>n</code> keys at once,
|
||||||
|
all strictly between the bounds and in ascending order — useful for seeding a list or
|
||||||
|
bulk-inserting a run of items. Because a key is just a value on the item, <strong>moving</strong>
|
||||||
|
an item is a single-field write: compute a new key between its new neighbors and re-sort.
|
||||||
|
Nothing else in the list is touched, which is exactly what makes concurrent reorders converge
|
||||||
|
cleanly (they reduce to independent
|
||||||
|
<NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink> writes on each item's key).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="fractionalBatch" lang="ts" />
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
<strong class="text-amber-700 dark:text-amber-400">Heads up:</strong>
|
||||||
|
<code class="text-(--accent-text)">keyBetween</code> requires <code>lower < upper</code>
|
||||||
|
and throws otherwise. Two replicas independently generating a key between the
|
||||||
|
<em>same</em> neighbors can produce identical keys; pair the key with the item's op id as a
|
||||||
|
secondary sort to keep ordering deterministic, or let
|
||||||
|
<NuxtLink to="/crdt/rga">Rga</NuxtLink> handle character-level ordering for you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sequence -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>The RGA sequence</h2>
|
||||||
|
<p>
|
||||||
|
<NuxtLink to="/crdt/rga">Rga</NuxtLink> (Replicated Growable Array) is the heart of the
|
||||||
|
package — the CRDT behind collaborative text. Each element is a node with a unique
|
||||||
|
<code>OpId</code>, a value, and an <code>originLeft</code>: the id of the element it was
|
||||||
|
inserted <em>after</em> (<code>null</code> means the start of the sequence). Deletion never
|
||||||
|
removes a node; it sets a <strong>tombstone</strong> flag, so the node lives on as a stable
|
||||||
|
anchor that later inserts and marks can still reference.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<code>integrateInsert(id, value, originLeft)</code> and <code>integrateDelete(id)</code> are
|
||||||
|
both idempotent — re-integrating an op you've already seen is a no-op that safely returns
|
||||||
|
<code>true</code>. Read the visible state with <code>toArray()</code>; use
|
||||||
|
<code>visible()</code> to get the surviving nodes (and their ids) for cursor anchoring, and
|
||||||
|
<code>length</code> for the visible count.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="rgaBasic" lang="ts" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h3>Concurrent inserts and the tie-break</h3>
|
||||||
|
<p>
|
||||||
|
The interesting case is two replicas inserting at the <em>same</em> origin at the same time.
|
||||||
|
Both new elements claim the slot right after the same left neighbor — so which goes first? RGA
|
||||||
|
resolves this deterministically: among elements sharing an origin, the one with the
|
||||||
|
<strong>higher op id</strong> is placed first (<code>compareOpId > 0</code> scans past it).
|
||||||
|
Because every replica applies the identical comparison, they all settle on the same order
|
||||||
|
without any coordination.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="rgaConverge" lang="ts" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h3>Causal buffering</h3>
|
||||||
|
<p>
|
||||||
|
RGA requires inserts to be integrated <strong>in causal order</strong>: an element's
|
||||||
|
<code>originLeft</code> must already be present, or there's no anchor to insert after. Rather
|
||||||
|
than guess, <code>integrateInsert</code> returns <code>false</code> when the origin is missing
|
||||||
|
and <code>integrateDelete</code> returns <code>false</code> for an unknown target — the signal
|
||||||
|
to <em>buffer</em> the op and retry once its dependency lands. (At a higher level,
|
||||||
|
<NuxtLink to="/crdt/replica">Replica</NuxtLink> does this bookkeeping for you, holding and
|
||||||
|
replaying ops automatically.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="rgaBuffer" lang="ts" />
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
<strong class="text-(--fg)">Garbage collection.</strong> Tombstones accumulate. When every
|
||||||
|
replica has fully synced and nothing is in flight, <code class="text-(--accent-text)">gc(stable, keep?)</code>
|
||||||
|
drops deleted nodes whose insert is covered by a stable
|
||||||
|
<NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink>, returning how many it removed.
|
||||||
|
Run it only at quiescence — a late op that uses a dropped node as its origin could no longer
|
||||||
|
integrate — and pass <code class="text-(--accent-text)">keep</code> to protect ids still
|
||||||
|
referenced elsewhere, such as mark span endpoints.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Marks -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Marks (lightweight Peritext)</h2>
|
||||||
|
<p>
|
||||||
|
Formatting in a collaborative editor can't be stored by offset — insert a character and every
|
||||||
|
offset after it shifts, so a "bold from 3 to 7" range would drift onto the wrong text.
|
||||||
|
<NuxtLink to="/crdt/mark-store">MarkStore</NuxtLink> follows the
|
||||||
|
<a href="https://www.inkandswitch.com/peritext/" target="_blank" rel="noopener">Peritext</a>
|
||||||
|
model: a <code>MarkSpan</code> anchors to the <code>OpId</code> of its first and last
|
||||||
|
characters (an inclusive range), so the span moves with the text it covers as the sequence
|
||||||
|
grows and shrinks around it.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
A span's <code>value</code> is a JSON-serializable <code>MarkValue</code> — pass
|
||||||
|
<code>true</code> (or attributes like a color string) to apply the mark, and
|
||||||
|
<code>null</code> or <code>false</code> to clear it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="marks" lang="ts" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h3>Resolving and converging</h3>
|
||||||
|
<p>
|
||||||
|
<code>add(span)</code> just records a span (idempotent by span id). The real work is
|
||||||
|
<code>resolve(order)</code>: given the character op ids in document order — typically
|
||||||
|
<code>rga.visible().map(n => n.id)</code> — it returns one <code>Map<type, value></code>
|
||||||
|
of active marks per character. For each character and mark type, the covering span with the
|
||||||
|
<strong>highest op id wins</strong>, so concurrent formatting converges by the same
|
||||||
|
<code>compareOpId</code> rule as everything else; a winning <code>null</code>/<code>false</code>
|
||||||
|
span clears the mark.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="marksConflict" lang="ts" />
|
||||||
|
|
||||||
|
<!-- Where next -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Where to next</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/concepts">Concepts</NuxtLink> — op ids, Lamport clocks, version vectors,
|
||||||
|
and why convergence holds across all of these primitives.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/replication">Replication & Sync</NuxtLink> — wire the primitives to a
|
||||||
|
<NuxtLink to="/crdt/replica">Replica</NuxtLink> for delta sync and automatic causal
|
||||||
|
buffering.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/playground">Playground</NuxtLink> — watch two replicas diverge and
|
||||||
|
reconcile, live in the browser.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
<!-- title: Replication & Sync -->
|
||||||
|
<!-- order: 3 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
const opLogShape = `import { OpLog } from '@robonen/crdt';
|
||||||
|
|
||||||
|
// The op log only ever reads \`id\` — the rest of the op is your domain payload.
|
||||||
|
interface CharOp {
|
||||||
|
id: { site: string; clock: number };
|
||||||
|
originLeft: { site: string; clock: number } | null;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = new OpLog<CharOp>();
|
||||||
|
|
||||||
|
log.append(op); // true if new, false if already seen (dedup by id)
|
||||||
|
log.has(op.id); // version vector lookup, not a linear scan
|
||||||
|
log.version; // VersionVector — the highest clock seen per site
|
||||||
|
log.all(); // every op, in append order
|
||||||
|
log.delta(remoteVector); // ops the remote (described by its vector) lacks`;
|
||||||
|
|
||||||
|
const deltaExample = `// A asks B: "here's everything I've seen" (a state vector).
|
||||||
|
const aWants = a.replica.version;
|
||||||
|
|
||||||
|
// B answers with exactly the ops A is missing — nothing more.
|
||||||
|
const patch = b.replica.delta(aWants); // OpLog.delta filters by the vector
|
||||||
|
|
||||||
|
// A integrates them; ids it already has are silently dropped.
|
||||||
|
a.replica.receive(patch);`;
|
||||||
|
|
||||||
|
const roundTrip = `import { Replica, Rga } from '@robonen/crdt';
|
||||||
|
import type { OpId } from '@robonen/crdt';
|
||||||
|
|
||||||
|
interface CharOp {
|
||||||
|
id: OpId;
|
||||||
|
originLeft: OpId | null;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each site owns an RGA (the sequence state) behind a Replica
|
||||||
|
// (clock + op log + causal buffer + delta sync).
|
||||||
|
function makeReplica(site: string) {
|
||||||
|
const rga = new Rga<string>();
|
||||||
|
const replica = new Replica<CharOp>(
|
||||||
|
{ integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) },
|
||||||
|
site,
|
||||||
|
);
|
||||||
|
return { rga, replica };
|
||||||
|
}
|
||||||
|
|
||||||
|
function type(peer: ReturnType<typeof makeReplica>, text: string): void {
|
||||||
|
let left: OpId | null = null;
|
||||||
|
for (const ch of text) {
|
||||||
|
const id = peer.replica.nextId(); // tick the Lamport clock
|
||||||
|
peer.replica.commitLocal({ id, originLeft: left, value: ch });
|
||||||
|
left = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = makeReplica('a');
|
||||||
|
const b = makeReplica('b');
|
||||||
|
|
||||||
|
// Concurrent, independent edits — neither has seen the other.
|
||||||
|
type(a, 'Hi');
|
||||||
|
type(b, 'Yo');
|
||||||
|
|
||||||
|
// Exchange ONLY the delta each side is missing, in both directions.
|
||||||
|
b.replica.receive(a.replica.delta(b.replica.version));
|
||||||
|
a.replica.receive(b.replica.delta(a.replica.version));
|
||||||
|
|
||||||
|
a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged
|
||||||
|
a.rga.length; // 4`;
|
||||||
|
|
||||||
|
const bufferingExample = `const a = makeReplica('a');
|
||||||
|
type(a, 'ab'); // two ops; the 2nd inserts after (depends on) the 1st
|
||||||
|
|
||||||
|
const b = makeReplica('b');
|
||||||
|
const [op1, op2] = a.replica.delta(b.replica.version);
|
||||||
|
|
||||||
|
// Deliver the DEPENDENT op first. Its origin (op1) isn't present,
|
||||||
|
// so integrate() returns false and the replica buffers it.
|
||||||
|
b.replica.receive([op2]);
|
||||||
|
b.rga.toArray().join(''); // '' — nothing applied yet
|
||||||
|
|
||||||
|
// Now deliver the dependency. op1 integrates, which unblocks op2;
|
||||||
|
// drain() loops until no further progress is possible.
|
||||||
|
b.replica.receive([op1]);
|
||||||
|
b.rga.toArray().join(''); // 'ab'`;
|
||||||
|
|
||||||
|
const wireExample = `import {
|
||||||
|
encodeStateVector, decodeStateVector,
|
||||||
|
encodeOps, decodeOps,
|
||||||
|
} from '@robonen/crdt';
|
||||||
|
|
||||||
|
// --- Peer A: announce what I have ---
|
||||||
|
const myVector: Uint8Array = encodeStateVector(a.replica.version);
|
||||||
|
socket.send(myVector); // send over WebSocket, HTTP, BroadcastChannel, …
|
||||||
|
|
||||||
|
// --- Peer B: answer with the delta A is missing ---
|
||||||
|
const remoteVector = decodeStateVector(received);
|
||||||
|
const patch: Uint8Array = encodeOps(b.replica.delta(remoteVector));
|
||||||
|
socket.send(patch);
|
||||||
|
|
||||||
|
// --- Peer A: apply the patch ---
|
||||||
|
const ops = decodeOps<CharOp>(receivedPatch);
|
||||||
|
a.replica.receive(ops);`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<!-- Intro -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>Replication & Sync</h1>
|
||||||
|
<p>
|
||||||
|
A CRDT primitive on its own guarantees that the <em>same</em> set of operations converges.
|
||||||
|
Replication is the layer that makes sure every replica eventually holds that same set —
|
||||||
|
despite messages arriving out of order, twice, or after a long offline gap. This package
|
||||||
|
does it without a central server, a global lock, or full-state diffing: each replica keeps
|
||||||
|
an append-only op log keyed by a version vector, and the two sides exchange only the
|
||||||
|
operations the other is missing.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The pieces fit together in one direction. <code>OpLog</code> stores ops and tracks a
|
||||||
|
<code>VersionVector</code>; <code>Replica</code> wraps a log plus a Lamport clock and a
|
||||||
|
causal buffer, integrating local and remote ops into your domain state; and the
|
||||||
|
<code>sync</code> helpers turn version vectors and op batches into bytes for any transport.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mental model -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>The convergence model</h2>
|
||||||
|
<p>
|
||||||
|
Every operation carries a globally-unique <code>OpId</code> — a per-site
|
||||||
|
<a href="https://en.wikipedia.org/wiki/Lamport_timestamp">Lamport</a> clock value tagged
|
||||||
|
with the site that produced it (<code>{ site, clock }</code>). Two facts make replication
|
||||||
|
work, and both flow from that id:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Identity ⇒ idempotence.</strong> Because an op's id is stable, a replica can tell
|
||||||
|
whether it has already seen an op and apply it at most once. Delivering the same op twice
|
||||||
|
is a no-op, so duplicate or replayed messages are harmless.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Determinism ⇒ commutativity.</strong> Concurrent ops are resolved by one shared
|
||||||
|
tie-break — <NuxtLink to="/crdt/compare-op-id">compareOpId</NuxtLink> (higher clock wins,
|
||||||
|
site id breaks ties). Since every replica agrees on it, the order ops arrive in doesn't
|
||||||
|
change the final state.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Replication therefore reduces to a set-reconciliation problem: <em>get both replicas to the
|
||||||
|
same set of ops.</em> Convergence of the resulting state is the primitive's job; getting the
|
||||||
|
ops there efficiently is this layer's.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Version vectors -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h3>Version vectors: "what have you seen?"</h3>
|
||||||
|
<p>
|
||||||
|
A <NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink> is the compact summary of
|
||||||
|
everything a replica has observed — a map from site id to the highest clock seen for that
|
||||||
|
site. It relies on one assumption: each site emits <strong>dense</strong> clocks
|
||||||
|
(1, 2, 3, …), with no gaps. That's what lets a single number per site stand in for a whole
|
||||||
|
set: if a replica has seen <code>a@5</code>, it has necessarily seen <code>a@1…a@4</code>
|
||||||
|
too. So <code>has(id)</code> is just <code>get(id.site) >= id.clock</code> — an O(1)
|
||||||
|
check, no per-op bookkeeping.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Two operations follow directly. <strong>Dedup:</strong> an incoming op whose id the vector
|
||||||
|
already covers can be ignored. <strong>Delta:</strong> given a remote vector, the set of ops
|
||||||
|
the remote lacks is exactly those whose id the vector does <em>not</em> cover.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OpLog -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>The op log</h2>
|
||||||
|
<p>
|
||||||
|
<NuxtLink to="/crdt/op-log">OpLog</NuxtLink> is an append-only list of operations paired
|
||||||
|
with a version vector. It is deliberately domain-agnostic: the only field it reads is
|
||||||
|
<code>id</code> (the <code>HasOpId</code> constraint), so the same log stores RGA inserts,
|
||||||
|
LWW writes, mark spans, or anything else you give it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="opLogShape" lang="ts" />
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
<code>append</code> consults the vector first and returns <code>false</code> if the op is a
|
||||||
|
duplicate, so the log never stores the same id twice. <code>delta(remote)</code> walks the
|
||||||
|
log once and keeps every op the remote vector hasn't covered — this is the heart of
|
||||||
|
"exchange only the delta".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="deltaExample" lang="ts" />
|
||||||
|
|
||||||
|
<!-- Replica -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>The replica</h2>
|
||||||
|
<p>
|
||||||
|
<NuxtLink to="/crdt/replica">Replica</NuxtLink> ties everything together. It owns a
|
||||||
|
<code>LamportClock</code>, an <code>OpLog</code>, and a pending buffer, and you give it a
|
||||||
|
single handler — <code>integrate(op)</code> — that applies an op to your domain state and
|
||||||
|
returns <code>false</code> when the op's causal dependencies aren't present yet.
|
||||||
|
</p>
|
||||||
|
<h3>Producing local ops</h3>
|
||||||
|
<p>
|
||||||
|
Call <code>nextId()</code> to tick the clock and mint a fresh, causally-later
|
||||||
|
<code>OpId</code>, build your op around it, then hand it to <code>commitLocal(op)</code>.
|
||||||
|
That logs it, integrates it into local state, and notifies <code>onUpdate</code> listeners
|
||||||
|
with origin <code>'local'</code>. Because <code>nextId</code> advances a Lamport clock that
|
||||||
|
also tracks observed remote ops, locally-generated ids are always ordered after everything
|
||||||
|
the replica has seen.
|
||||||
|
</p>
|
||||||
|
<h3>Receiving remote ops</h3>
|
||||||
|
<p>
|
||||||
|
<code>receive(ops)</code> is the inbound path. For each op it advances the clock past the
|
||||||
|
remote id (<code>clock.observe</code>), skips anything already logged or already buffered,
|
||||||
|
then drains the buffer — integrating whatever is now causally ready, retrying until no
|
||||||
|
further progress is possible. It returns the ops it actually applied (in apply order) and
|
||||||
|
notifies listeners with origin <code>'remote'</code>.
|
||||||
|
</p>
|
||||||
|
<h3>Computing a delta</h3>
|
||||||
|
<p>
|
||||||
|
<code>delta(remoteVector)</code> forwards to the log: the ops this replica holds that the
|
||||||
|
remote, described by its <code>version</code>, has not seen. The whole round-trip is two
|
||||||
|
deltas — one per direction.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Round trip -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>The canonical round-trip</h2>
|
||||||
|
<p>
|
||||||
|
Here is the README's converging-string example expanded end to end. Two replicas type
|
||||||
|
concurrently, then each side sends the other exactly the ops it lacks. After both deltas,
|
||||||
|
they hold the identical op set and therefore the identical string.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="roundTrip" lang="ts" />
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Note the asymmetry that makes this efficient: <code>a.replica.delta(b.replica.version)</code>
|
||||||
|
is computed against <em>B's</em> vector, so it returns only what B is missing — not A's
|
||||||
|
entire history. On a long document this is the difference between sending two characters and
|
||||||
|
re-sending the whole file.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Why order does not matter -->
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Why the order of the two deltas is irrelevant</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
You could swap the two <code class="text-(--accent-text)">receive</code> lines, run them
|
||||||
|
repeatedly, or interleave them with more edits — the result is the same. Each side only ever
|
||||||
|
adds ops it hasn't seen, and <code class="text-(--accent-text)">compareOpId</code> places
|
||||||
|
each op in its deterministic position regardless of arrival order. That is convergence,
|
||||||
|
and the property tests assert it across randomized schedules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Causal buffering -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Causal buffering</h2>
|
||||||
|
<p>
|
||||||
|
Some ops can't be applied the instant they arrive. An RGA insert references an
|
||||||
|
<code>originLeft</code> — the element it goes after — and a delete references the element it
|
||||||
|
tombstones. If that target hasn't been integrated yet (a later op overtook an earlier one in
|
||||||
|
transit), the insert has nowhere to anchor.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The handler signals this by returning <code>false</code> from <code>integrate</code>:
|
||||||
|
<NuxtLink to="/crdt/rga">Rga</NuxtLink>'s <code>integrateInsert</code> returns
|
||||||
|
<code>false</code> when its origin is absent, and <code>integrateDelete</code> returns
|
||||||
|
<code>false</code> when its target is unknown. <code>Replica.receive</code> treats a
|
||||||
|
<code>false</code> as "not ready yet": it keeps the op in a pending buffer and re-runs the
|
||||||
|
buffer every time new ops land, until either the op integrates or its dependency finally
|
||||||
|
arrives. Nothing is lost; nothing is applied prematurely.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="bufferingExample" lang="ts" />
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Internally the drain loop sweeps the buffer repeatedly: each successful integration may
|
||||||
|
unblock another buffered op, so it keeps looping while it makes progress. This is why a
|
||||||
|
single <code>receive</code> of a batch delivered in any order still settles to the right
|
||||||
|
state — the buffer absorbs the disorder.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wire encoding -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Transport-agnostic wire encoding</h2>
|
||||||
|
<p>
|
||||||
|
The <code>sync</code> module is the only part that touches bytes, and it stays small on
|
||||||
|
purpose. There are two things to put on the wire — a version vector (the "what do you have?"
|
||||||
|
handshake) and a batch of ops (the delta or a full snapshot) — and a helper for each
|
||||||
|
direction:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/encode-state-vector">encodeStateVector</NuxtLink> /
|
||||||
|
<code>decodeStateVector</code> — a <code>VersionVector</code> ⇄ <code>Uint8Array</code>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/encode-ops">encodeOps</NuxtLink> / <code>decodeOps</code> — an op
|
||||||
|
batch (the delta or a full snapshot) ⇄ <code>Uint8Array</code>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code>encodeJson</code> / <code>decodeJson</code> — the lower-level pair the others build on.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
The v1 format is JSON encoded to bytes — simple and debuggable. A compact varint format is a
|
||||||
|
later optimization that changes the bytes, not the API, so code written against these
|
||||||
|
functions keeps working. Because the result is just a <code>Uint8Array</code>, the transport
|
||||||
|
is entirely up to you: WebSocket, HTTP, <code>BroadcastChannel</code>, a file on disk.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="wireExample" lang="ts" />
|
||||||
|
|
||||||
|
<!-- A typical sync protocol -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h3>A minimal two-way protocol</h3>
|
||||||
|
<p>
|
||||||
|
Put the pieces together and a full reconciliation between two peers is four messages:
|
||||||
|
</p>
|
||||||
|
<ol>
|
||||||
|
<li>Each peer sends its <code>encodeStateVector(replica.version)</code>.</li>
|
||||||
|
<li>
|
||||||
|
On receiving the other's vector, each peer replies with
|
||||||
|
<code>encodeOps(replica.delta(theirVector))</code>.
|
||||||
|
</li>
|
||||||
|
<li>Each peer <code>receive()</code>s the decoded delta.</li>
|
||||||
|
<li>Both replicas now hold the same op set — and the same converged state.</li>
|
||||||
|
</ol>
|
||||||
|
<p>
|
||||||
|
This generalizes cleanly. For live collaboration, also forward each locally-committed op as
|
||||||
|
it happens (subscribe with <code>onUpdate</code>, encode the op, broadcast it); peers that
|
||||||
|
receive an op out of causal order simply buffer it. For catch-up after an offline gap, the
|
||||||
|
state-vector handshake above replays exactly the missed ops. The same machinery covers both.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Caveat callout -->
|
||||||
|
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-amber-700 dark:text-amber-400">Dense clocks are a precondition</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
Version vectors assume each site's clocks are dense (1, 2, 3, …). That holds automatically
|
||||||
|
when ids come from <code class="text-(--accent-text)">Replica.nextId()</code>. If you mint
|
||||||
|
ids yourself, never skip a value for a site — a gap would make
|
||||||
|
<code class="text-(--accent-text)">delta</code> believe a missing op was already delivered.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Where next -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Where to next</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/replica">Replica</NuxtLink> — the full API reference for
|
||||||
|
<code>commitLocal</code>, <code>receive</code>, <code>delta</code>, and
|
||||||
|
<code>onUpdate</code>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/op-log">OpLog</NuxtLink> and
|
||||||
|
<NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink> — the storage and causality
|
||||||
|
primitives underneath.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/playground">Playground</NuxtLink> — watch two replicas diverge and
|
||||||
|
reconcile live in the browser.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
<!-- title: Playground -->
|
||||||
|
<!-- order: 4 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from 'vue';
|
||||||
|
import type { OpId } from '../src';
|
||||||
|
import { Replica, Rga } from '../src';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The op shape exchanged between replicas.
|
||||||
|
//
|
||||||
|
// This is a REAL @robonen/crdt setup, not a simulation: each side owns an `Rga`
|
||||||
|
// for its sequence state, wrapped by a `Replica` that owns the Lamport clock,
|
||||||
|
// op log, causal buffer and delta computation. The only thing the demo adds is
|
||||||
|
// a tiny op union so we can both insert and delete characters.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
type CharOp =
|
||||||
|
| { kind: 'insert'; id: OpId; value: string; originLeft: OpId | null }
|
||||||
|
| { kind: 'delete'; id: OpId; target: OpId };
|
||||||
|
|
||||||
|
interface Side {
|
||||||
|
rga: Rga<string>;
|
||||||
|
replica: Replica<CharOp>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSide(site: string): Side {
|
||||||
|
const rga = new Rga<string>();
|
||||||
|
const replica = new Replica<CharOp>(
|
||||||
|
{
|
||||||
|
// Return `false` when a dependency is missing — the Replica buffers the op
|
||||||
|
// and retries it automatically once the dependency arrives.
|
||||||
|
integrate: (op) => {
|
||||||
|
if (op.kind === 'insert')
|
||||||
|
return rga.integrateInsert(op.id, op.value, op.originLeft);
|
||||||
|
return rga.integrateDelete(op.target);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
site,
|
||||||
|
);
|
||||||
|
return { rga, replica };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactive view-model. The CRDT classes are plain (non-reactive) objects, so we
|
||||||
|
// keep a small reactive snapshot and refresh it after every mutation.
|
||||||
|
interface View {
|
||||||
|
text: string;
|
||||||
|
ops: number;
|
||||||
|
clock: number;
|
||||||
|
pending: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = reactive<{ a: View; b: View }>({
|
||||||
|
a: { text: '', ops: 0, clock: 0, pending: 0 },
|
||||||
|
b: { text: '', ops: 0, clock: 0, pending: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const drafts = reactive({ a: '', b: '' });
|
||||||
|
let a: Side | null = null;
|
||||||
|
let b: Side | null = null;
|
||||||
|
|
||||||
|
function refresh(): void {
|
||||||
|
if (!a || !b)
|
||||||
|
return;
|
||||||
|
snapshot.a = {
|
||||||
|
text: a.rga.toArray().join(''),
|
||||||
|
ops: a.replica.version.get(a.replica.site),
|
||||||
|
clock: a.replica.version.get(a.replica.site),
|
||||||
|
pending: 0,
|
||||||
|
};
|
||||||
|
snapshot.b = {
|
||||||
|
text: b.rga.toArray().join(''),
|
||||||
|
ops: b.replica.version.get(b.replica.site),
|
||||||
|
clock: b.replica.version.get(b.replica.site),
|
||||||
|
pending: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(): void {
|
||||||
|
a = makeSide('A');
|
||||||
|
b = makeSide('B');
|
||||||
|
drafts.a = '';
|
||||||
|
drafts.b = '';
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The drafts are deliberately decoupled from the CRDT value until "Apply":
|
||||||
|
// that lets the user stage CONCURRENT edits on both sides before any sync, the
|
||||||
|
// scenario where convergence actually matters.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff the side's current CRDT string against the textarea draft and emit the
|
||||||
|
* minimal insert/delete ops to make the RGA match the draft, committing each
|
||||||
|
* locally. A real editor derives these ops from input events the same way.
|
||||||
|
*/
|
||||||
|
function apply(which: 'a' | 'b'): void {
|
||||||
|
const side = which === 'a' ? a : b;
|
||||||
|
if (!side)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const current = side.rga.toArray();
|
||||||
|
const next = [...(which === 'a' ? drafts.a : drafts.b)];
|
||||||
|
|
||||||
|
// Longest common prefix / suffix → splice region (a tiny, dependency-free diff).
|
||||||
|
let start = 0;
|
||||||
|
while (start < current.length && start < next.length && current[start] === next[start])
|
||||||
|
start += 1;
|
||||||
|
|
||||||
|
let endCur = current.length;
|
||||||
|
let endNext = next.length;
|
||||||
|
while (endCur > start && endNext > start && current[endCur - 1] === next[endNext - 1]) {
|
||||||
|
endCur -= 1;
|
||||||
|
endNext -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the removed characters (right-to-left keeps live indices stable).
|
||||||
|
for (let i = endCur - 1; i >= start; i--) {
|
||||||
|
const target = side.rga.idAt(i);
|
||||||
|
if (target) {
|
||||||
|
const op: CharOp = { kind: 'delete', id: side.replica.nextId(), target };
|
||||||
|
side.replica.commitLocal(op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the new characters after the surviving left neighbour.
|
||||||
|
let left = start > 0 ? side.rga.idAt(start - 1) : null;
|
||||||
|
for (let i = start; i < endNext; i++) {
|
||||||
|
const op: CharOp = {
|
||||||
|
kind: 'insert',
|
||||||
|
id: side.replica.nextId(),
|
||||||
|
value: next[i]!,
|
||||||
|
originLeft: left,
|
||||||
|
};
|
||||||
|
side.replica.commitLocal(op);
|
||||||
|
left = op.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read drafts from the authoritative CRDT value.
|
||||||
|
drafts.a = a!.rga.toArray().join('');
|
||||||
|
drafts.b = b!.rga.toArray().join('');
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One full sync round: each side hands the other only the ops it is missing
|
||||||
|
* (computed from the peer's version vector), and `receive` integrates them with
|
||||||
|
* dedup + causal buffering. After this both RGAs hold the identical sequence.
|
||||||
|
*/
|
||||||
|
function sync(): void {
|
||||||
|
if (!a || !b)
|
||||||
|
return;
|
||||||
|
// Snapshot versions BEFORE exchanging so each delta reflects pre-sync state.
|
||||||
|
const va = a.replica.version.clone();
|
||||||
|
const vb = b.replica.version.clone();
|
||||||
|
b.replica.receive(a.replica.delta(vb));
|
||||||
|
a.replica.receive(b.replica.delta(va));
|
||||||
|
drafts.a = a.rga.toArray().join('');
|
||||||
|
drafts.b = b.rga.toArray().join('');
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = ref(false);
|
||||||
|
function start(): void {
|
||||||
|
if (ready.value)
|
||||||
|
return;
|
||||||
|
init();
|
||||||
|
ready.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const converged = computed(() =>
|
||||||
|
snapshot.a.text === snapshot.b.text && (snapshot.a.text.length > 0 || snapshot.a.ops > 0));
|
||||||
|
|
||||||
|
// --- static code samples ---------------------------------------------------
|
||||||
|
const setupCode = `import { Replica, Rga } from '@robonen/crdt';
|
||||||
|
import type { OpId } from '@robonen/crdt';
|
||||||
|
|
||||||
|
// Inserts and deletes travel as ops. Every op carries an \`id\`; that's all
|
||||||
|
// Replica's op log needs to dedup and compute deltas.
|
||||||
|
type CharOp =
|
||||||
|
| { kind: 'insert'; id: OpId; value: string; originLeft: OpId | null }
|
||||||
|
| { kind: 'delete'; id: OpId; target: OpId };
|
||||||
|
|
||||||
|
function makeSide(site: string) {
|
||||||
|
const rga = new Rga<string>();
|
||||||
|
const replica = new Replica<CharOp>(
|
||||||
|
{
|
||||||
|
// Return false when a causal dependency is missing — the Replica buffers
|
||||||
|
// the op and retries it automatically once the dependency lands.
|
||||||
|
integrate: (op) =>
|
||||||
|
op.kind === 'insert'
|
||||||
|
? rga.integrateInsert(op.id, op.value, op.originLeft)
|
||||||
|
: rga.integrateDelete(op.target),
|
||||||
|
},
|
||||||
|
site,
|
||||||
|
);
|
||||||
|
return { rga, replica };
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = makeSide('A');
|
||||||
|
const b = makeSide('B');`;
|
||||||
|
|
||||||
|
const localEditCode = `// A types "cat" at the start. Each character is an insert anchored to the
|
||||||
|
// previous one via originLeft; nextId() advances A's Lamport clock.
|
||||||
|
let left: OpId | null = null;
|
||||||
|
for (const ch of 'cat') {
|
||||||
|
const op = { kind: 'insert', id: a.replica.nextId(), value: ch, originLeft: left } as const;
|
||||||
|
a.replica.commitLocal(op); // integrate locally + append to the log
|
||||||
|
left = op.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrently, B types "dog" — it has NOT seen A's ops yet.
|
||||||
|
left = null;
|
||||||
|
for (const ch of 'dog') {
|
||||||
|
const op = { kind: 'insert', id: b.replica.nextId(), value: ch, originLeft: left } as const;
|
||||||
|
b.replica.commitLocal(op);
|
||||||
|
left = op.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.rga.toArray().join(''); // 'cat'
|
||||||
|
b.rga.toArray().join(''); // 'dog' — the replicas have DIVERGED`;
|
||||||
|
|
||||||
|
const syncCode = `// Send each side only what it's missing, computed from the peer's version.
|
||||||
|
// Snapshot versions first so both deltas describe the pre-sync state.
|
||||||
|
const va = a.replica.version.clone();
|
||||||
|
const vb = b.replica.version.clone();
|
||||||
|
|
||||||
|
b.replica.receive(a.replica.delta(vb)); // B integrates A's 3 inserts
|
||||||
|
a.replica.receive(b.replica.delta(va)); // A integrates B's 3 inserts
|
||||||
|
|
||||||
|
// Both RGAs now hold the same six characters in the same order. The order is
|
||||||
|
// decided by compareOpId (higher clock wins; site id breaks the tie) — NOT by
|
||||||
|
// who synced first — so the result is identical on every replica.
|
||||||
|
a.rga.toArray().join(''); // e.g. 'dogcat'
|
||||||
|
a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<!-- Intro -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>Playground</h1>
|
||||||
|
<p>
|
||||||
|
Reading about convergence only gets you so far — the intuition lands when you
|
||||||
|
<em>watch two replicas disagree and then reconcile</em>. Below is a live, two-replica
|
||||||
|
editor backed by the real <NuxtLink to="/crdt/rga">Rga</NuxtLink> and
|
||||||
|
<NuxtLink to="/crdt/replica">Replica</NuxtLink> classes from this package. Edit each side
|
||||||
|
independently, then press <strong>Sync</strong> and see them land on the exact same string.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live demo -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Live: two replicas, one string</h2>
|
||||||
|
<p>
|
||||||
|
Replica <strong>A</strong> and replica <strong>B</strong> each own a private copy of a
|
||||||
|
shared document. Type something different into each, click <strong>Apply</strong> to commit
|
||||||
|
those edits locally (they diverge), then <strong>Sync</strong> to exchange deltas and
|
||||||
|
converge. The readout under each side shows its current value, how many local ops its log
|
||||||
|
has produced, and its Lamport clock.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ClientOnly>
|
||||||
|
<template #fallback>
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-8 text-center text-sm text-(--fg-subtle)">
|
||||||
|
Loading interactive demo…
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4 sm:p-5">
|
||||||
|
<div v-if="!ready" class="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
|
<p class="text-sm text-(--fg-muted)">Spin up two fresh replicas to start editing.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-(--accent) px-4 py-2 text-sm font-medium text-(--accent-fg) hover:bg-(--accent-hover) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
@click="start()"
|
||||||
|
>
|
||||||
|
Start demo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-col gap-4">
|
||||||
|
<!-- Two replica panes -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<!-- Replica A -->
|
||||||
|
<div class="flex flex-col gap-2 rounded-lg border border-(--border) bg-(--bg-elevated) p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wider text-(--fg-muted)">Replica A</span>
|
||||||
|
<span class="rounded bg-(--bg-inset) px-1.5 py-0.5 font-mono text-[11px] text-(--fg-subtle)">site: A</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="drafts.a"
|
||||||
|
rows="3"
|
||||||
|
spellcheck="false"
|
||||||
|
class="resize-none rounded-md border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) focus:border-(--border-strong) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
placeholder="Type on A…"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-xs font-medium text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
@click="apply('a')"
|
||||||
|
>
|
||||||
|
Apply edits
|
||||||
|
</button>
|
||||||
|
<div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-(--fg-subtle)">
|
||||||
|
<span>ops {{ snapshot.a.ops }}</span>
|
||||||
|
<span>clock {{ snapshot.a.clock }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md bg-(--bg-inset) px-3 py-2 font-mono text-sm text-(--fg) break-all min-h-9">
|
||||||
|
<span v-if="snapshot.a.text">{{ snapshot.a.text }}</span>
|
||||||
|
<span v-else class="text-(--fg-subtle)">(empty)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Replica B -->
|
||||||
|
<div class="flex flex-col gap-2 rounded-lg border border-(--border) bg-(--bg-elevated) p-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-semibold uppercase tracking-wider text-(--fg-muted)">Replica B</span>
|
||||||
|
<span class="rounded bg-(--bg-inset) px-1.5 py-0.5 font-mono text-[11px] text-(--fg-subtle)">site: B</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="drafts.b"
|
||||||
|
rows="3"
|
||||||
|
spellcheck="false"
|
||||||
|
class="resize-none rounded-md border border-(--border) bg-(--bg) px-3 py-2 font-mono text-sm text-(--fg) focus:border-(--border-strong) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
placeholder="Type on B…"
|
||||||
|
/>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-(--border) bg-(--bg-elevated) px-3 py-1.5 text-xs font-medium text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
@click="apply('b')"
|
||||||
|
>
|
||||||
|
Apply edits
|
||||||
|
</button>
|
||||||
|
<div class="ml-auto flex items-center gap-3 font-mono text-[11px] text-(--fg-subtle)">
|
||||||
|
<span>ops {{ snapshot.b.ops }}</span>
|
||||||
|
<span>clock {{ snapshot.b.clock }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-md bg-(--bg-inset) px-3 py-2 font-mono text-sm text-(--fg) break-all min-h-9">
|
||||||
|
<span v-if="snapshot.b.text">{{ snapshot.b.text }}</span>
|
||||||
|
<span v-else class="text-(--fg-subtle)">(empty)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync bar -->
|
||||||
|
<div class="flex flex-wrap items-center gap-3 border-t border-(--border) pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-(--accent) px-4 py-2 text-sm font-medium text-(--accent-fg) hover:bg-(--accent-hover) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
@click="sync()"
|
||||||
|
>
|
||||||
|
Sync ↔
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md px-3 py-2 text-sm text-(--fg-muted) hover:bg-(--bg-inset) hover:text-(--fg) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
@click="init()"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
v-if="converged"
|
||||||
|
class="ml-auto inline-flex items-center gap-1.5 rounded-md bg-emerald-500/10 px-2.5 py-1 text-xs font-medium text-emerald-600 dark:text-emerald-400"
|
||||||
|
>
|
||||||
|
● Converged — both sides equal
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="ml-auto inline-flex items-center gap-1.5 rounded-md bg-amber-500/10 px-2.5 py-1 text-xs font-medium text-amber-600 dark:text-amber-400"
|
||||||
|
>
|
||||||
|
● Diverged — sync to reconcile
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Try the canonical experiment: type <code>cat</code> on A and <code>dog</code> on B, apply
|
||||||
|
both, then sync. The result is the same six characters on both sides, every time — the order
|
||||||
|
is decided by op id, not by who synced first. Reset and try it again to confirm it's
|
||||||
|
deterministic.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- How it works -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>How the demo is wired</h2>
|
||||||
|
<p>
|
||||||
|
There's no mock here. Each side is a real <code>Rga<string></code> wrapped in a
|
||||||
|
<code>Replica<CharOp></code>. The <code>Replica</code> owns the Lamport clock, the
|
||||||
|
append-only op log, the causal buffer, and delta computation; the <code>Rga</code> holds the
|
||||||
|
actual character sequence with tombstones. We pass one handler — <code>integrate</code> —
|
||||||
|
that applies an op to the RGA.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="setupCode" lang="ts" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h3>Making concurrent edits</h3>
|
||||||
|
<p>
|
||||||
|
A local edit is just an op: call <code>replica.nextId()</code> to mint a fresh op id (which
|
||||||
|
ticks that site's Lamport clock), build the insert or delete, and pass it to
|
||||||
|
<code>commitLocal</code>. That integrates the op into the RGA and appends it to the log in
|
||||||
|
one step. Because A and B edit before any sync, they produce ops with overlapping clock
|
||||||
|
values but different site ids — genuinely concurrent operations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="localEditCode" lang="ts" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h3>Syncing the deltas</h3>
|
||||||
|
<p>
|
||||||
|
Sync is a delta exchange driven by version vectors. Each replica's
|
||||||
|
<code>version</code> records the highest clock it has seen per site;
|
||||||
|
<code>delta(remoteVersion)</code> returns exactly the ops the remote is missing.
|
||||||
|
<code>receive</code> then dedups, integrates, and — crucially — <em>buffers</em> any op
|
||||||
|
whose causal dependency hasn't arrived yet, retrying it automatically once that dependency
|
||||||
|
lands.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="syncCode" lang="ts" />
|
||||||
|
|
||||||
|
<!-- Why it converges -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Why it always converges</h2>
|
||||||
|
<p>
|
||||||
|
The demo never special-cases conflicts, because the data structure can't have any. Three
|
||||||
|
properties, each verified by the package's property tests, guarantee that every replica
|
||||||
|
reaches the same state regardless of message order, duplication, or delay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Commutative</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
A-then-B and B-then-A produce the same sequence. Concurrent inserts at the same origin are
|
||||||
|
ordered by <code class="text-(--accent-text)">compareOpId</code>, so order of arrival
|
||||||
|
doesn't matter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Idempotent</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
Receiving the same op twice is a no-op. The op log's version vector dedups on
|
||||||
|
<code class="text-(--accent-text)">id</code>, and <code class="text-(--accent-text)">integrateInsert</code>
|
||||||
|
short-circuits if the id is already present.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Causal</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
An insert can't integrate before its <code class="text-(--accent-text)">originLeft</code>,
|
||||||
|
nor a delete before its target. <code class="text-(--accent-text)">receive</code> buffers
|
||||||
|
such ops and retries them, so out-of-order delivery still converges.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h3>The single source of truth: op id order</h3>
|
||||||
|
<p>
|
||||||
|
Everything hinges on one comparison. When two replicas insert characters at the same
|
||||||
|
position concurrently, <code>Rga.integrateInsert</code> walks past any existing siblings
|
||||||
|
whose op id sorts <em>higher</em> and splices the new node in — so the final order is fully
|
||||||
|
determined by <code>compareOpId</code>: higher Lamport clock first, with the site id as a
|
||||||
|
deterministic tie-break. Every replica runs the same comparison on the same ids, so they all
|
||||||
|
agree on the same order without a coordinator.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
That's also why deletes are tombstones rather than removals: a delete only flips a node's
|
||||||
|
<code>deleted</code> flag, so a concurrent insert that anchored to that node still has a
|
||||||
|
valid origin. The character disappears from <code>toArray()</code>, but the structure stays
|
||||||
|
intact for convergence. Tombstones are reclaimed later via
|
||||||
|
<NuxtLink to="/crdt/rga"><code>Rga.gc</code></NuxtLink>, but only at quiescence.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Things to try -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Experiments to try</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Repeat sync.</strong> Press <strong>Sync</strong> twice in a row — the second pass
|
||||||
|
applies nothing, because each side's delta is now empty. Idempotence in action.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Concurrent deletes.</strong> Sync to a shared value, then delete different
|
||||||
|
characters on each side and sync again. Both deletions survive; neither clobbers the other.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Edit after sync.</strong> Keep editing on one side and syncing repeatedly — only
|
||||||
|
the new ops travel each time, because <code>delta</code> filters by the peer's version
|
||||||
|
vector.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Tie-break.</strong> Type a single different character at the very start of each
|
||||||
|
side, then sync. The one whose op id sorts higher lands first — deterministically.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Where next -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Where to next</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/rga">Rga</NuxtLink> — the full sequence API: tombstones, cursor
|
||||||
|
anchoring via op ids, and garbage collection.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/replica">Replica</NuxtLink> — clock, op log, causal buffer, deltas,
|
||||||
|
and the <code>onUpdate</code> subscription used to drive UI.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/version-vector">VersionVector</NuxtLink> and
|
||||||
|
<NuxtLink to="/crdt/compare-op-id">compareOpId</NuxtLink> — the causality and tie-break
|
||||||
|
machinery behind every primitive.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const replicaExample = `import { Replica, Rga, opId } from '@robonen/crdt';
|
||||||
|
|
||||||
|
// Each editing site owns an RGA (the sequence state) wrapped by a Replica
|
||||||
|
// (clock + op log + causal buffering + delta sync).
|
||||||
|
type Op = {
|
||||||
|
id: ReturnType<typeof opId>;
|
||||||
|
value: string;
|
||||||
|
originLeft: ReturnType<typeof opId> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeReplica(site: string) {
|
||||||
|
const rga = new Rga<string>();
|
||||||
|
const replica = new Replica<Op>(
|
||||||
|
{ integrate: op => rga.integrateInsert(op.id, op.value, op.originLeft) },
|
||||||
|
site,
|
||||||
|
);
|
||||||
|
return { rga, replica };
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = makeReplica('a');
|
||||||
|
const b = makeReplica('b');
|
||||||
|
|
||||||
|
// A types "hi" locally.
|
||||||
|
let left: Op['originLeft'] = null;
|
||||||
|
for (const ch of 'hi') {
|
||||||
|
const op: Op = { id: a.replica.nextId(), value: ch, originLeft: left };
|
||||||
|
a.replica.commitLocal(op);
|
||||||
|
left = op.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync: send B only the ops it is missing, then send A only what it lacks.
|
||||||
|
b.replica.receive(a.replica.delta(b.replica.version));
|
||||||
|
a.replica.receive(b.replica.delta(a.replica.version));
|
||||||
|
|
||||||
|
a.rga.toArray().join(''); // 'hi'
|
||||||
|
a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<!-- Hero -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>@robonen/crdt</h1>
|
||||||
|
<p>
|
||||||
|
Framework-agnostic CRDT primitives — an RGA sequence, last-writer-wins registers,
|
||||||
|
fractional indexing, and version vectors that converge no matter the order, duplicates,
|
||||||
|
or delays in which operations arrive.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Collaborative state is hard because two replicas can edit the same document at once,
|
||||||
|
offline, with messages that arrive out of order or twice. A CRDT solves this by construction:
|
||||||
|
every primitive here is <strong>commutative, idempotent, and convergent</strong>, so applying
|
||||||
|
the same set of operations in any order yields the same state — a property verified by
|
||||||
|
property tests. It's the convergence engine behind <code>@robonen/editor</code>, but stays
|
||||||
|
fully domain-agnostic, ships zero runtime dependencies, and runs in both Node and the browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feature cards -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Convergent by construction</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
One deterministic tie-break — <code class="text-(--accent-text)">compareOpId</code> (higher
|
||||||
|
Lamport clock wins; site id breaks ties) — is shared by every primitive, so LWW and RGA agree
|
||||||
|
on the same final state.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Causal buffering built in</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
<code class="text-(--accent-text)">Replica.receive</code> dedups, holds ops whose dependencies
|
||||||
|
haven't arrived yet (an insert before its origin), and retries them automatically as they land.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Delta sync, not full state</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
Version vectors let each side request exactly the ops it's missing via
|
||||||
|
<code class="text-(--accent-text)">delta(version)</code>, with a transport-agnostic wire format.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Zero dependencies, pure TS</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
No runtime deps, no framework lock-in. Compose the primitives yourself, or lean on
|
||||||
|
<code class="text-(--accent-text)">Replica</code> to tie a clock, op log, and buffer together.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Install -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Install</h2>
|
||||||
|
<p>Add the package with your preferred package manager.</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="`pnpm add @robonen/crdt`" lang="bash" />
|
||||||
|
|
||||||
|
<!-- Usage -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Quick start</h2>
|
||||||
|
<p>
|
||||||
|
Two replicas edit a string independently, then exchange only the operations each is missing
|
||||||
|
and converge to the same result.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="replicaExample" lang="ts" />
|
||||||
|
|
||||||
|
<!-- Where next -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Where to next</h2>
|
||||||
|
<p>New to CRDTs? Work through the guide and finish in the live playground.</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/concepts">Concepts</NuxtLink> — op ids, Lamport clocks, version vectors,
|
||||||
|
and why convergence holds.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/primitives">Primitives</NuxtLink> — a tour of
|
||||||
|
<NuxtLink to="/crdt/rga">Rga</NuxtLink>,
|
||||||
|
<NuxtLink to="/crdt/lww-register">LwwRegister</NuxtLink>, and fractional indexing with
|
||||||
|
<NuxtLink to="/crdt/key-between">keyBetween</NuxtLink>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/replication">Replication & Sync</NuxtLink> — wiring up
|
||||||
|
<NuxtLink to="/crdt/replica">Replica</NuxtLink>, deltas, and the wire encoding.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/crdt/playground">Playground</NuxtLink> — watch two replicas diverge and
|
||||||
|
reconcile, live in the browser.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Landing hero for @robonen/encoding. Static content only — no live demo,
|
||||||
|
// so nothing here touches the DOM or runs at setup top-level.
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<!-- Hero -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>@robonen/encoding</h1>
|
||||||
|
<p>
|
||||||
|
Encoding utilities for TypeScript — a dependency-free QR Code generator
|
||||||
|
and the Reed-Solomon error-correction primitives that power it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Generating a QR Code correctly is deceptively hard: segment-mode selection,
|
||||||
|
version sizing, Reed-Solomon ECC over a finite field, block interleaving, and
|
||||||
|
the eight masking patterns each have to be just right for a scanner to read the
|
||||||
|
result. <code>@robonen/encoding</code> packages all of that into a small set of
|
||||||
|
pure functions and immutable classes, with zero runtime dependencies and an
|
||||||
|
output you render however you like — SVG, canvas, a DOM grid, or a terminal.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feature cards -->
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg)">High-level QR in one call</h3>
|
||||||
|
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||||
|
<code>encodeText</code> and <code>encodeBinary</code> pick the smallest
|
||||||
|
version and optimal segment modes for you, then hand back an immutable
|
||||||
|
<code>QrCode</code> grid.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg)">Render-agnostic output</h3>
|
||||||
|
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||||
|
A <code>QrCode</code> is just a square of modules. Read each one with
|
||||||
|
<code>getModule(x, y)</code> and draw to SVG, canvas, or anything else —
|
||||||
|
no rendering opinions baked in.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg)">Standalone Reed-Solomon</h3>
|
||||||
|
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||||
|
The GF(2^8) error-correction core — <code>multiply</code>,
|
||||||
|
<code>computeDivisor</code>, <code>computeRemainder</code> — is exported
|
||||||
|
on its own, reusable beyond QR.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg)">Zero dependencies, fully typed</h3>
|
||||||
|
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||||
|
Tree-shakeable ESM and CJS builds with no third-party runtime deps, hot
|
||||||
|
loops backed by typed arrays, and end-to-end TypeScript types.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Install -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Install</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="`pnpm add @robonen/encoding`" lang="bash" />
|
||||||
|
|
||||||
|
<!-- Usage -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Quick start</h2>
|
||||||
|
<p>
|
||||||
|
Encode a string into a <code>QrCode</code> and walk its modules to render
|
||||||
|
it. <code>EccMap.M</code> selects the medium (~15% recovery) error-correction
|
||||||
|
level; <code>EccMap.L</code>, <code>.Q</code>, and <code>.H</code> are also
|
||||||
|
available.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode lang="ts" :code="quickStart" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Need raw error-correction codewords without the QR machinery? The
|
||||||
|
Reed-Solomon primitives stand on their own:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode lang="ts" :code="reedSolomon" />
|
||||||
|
|
||||||
|
<!-- Where to next -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Where to next</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/encoding/encode-text">encodeText</NuxtLink> and
|
||||||
|
<NuxtLink to="/encoding/encode-binary">encodeBinary</NuxtLink> — the
|
||||||
|
high-level entry points for text and binary payloads.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/encoding/encode-segments">encodeSegments</NuxtLink> — the
|
||||||
|
mid-level API for mixed-mode payloads and version/mask control.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/encoding/qr-code">QrCode</NuxtLink> — the immutable grid,
|
||||||
|
with <code>size</code>, <code>getModule</code>, and <code>getType</code>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/encoding/compute-remainder">computeRemainder</NuxtLink> and
|
||||||
|
<NuxtLink to="/encoding/compute-divisor">computeDivisor</NuxtLink> — the
|
||||||
|
standalone Reed-Solomon core.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
const quickStart = `import { encodeText, EccMap } from '@robonen/encoding';
|
||||||
|
|
||||||
|
// Smallest version + optimal segment modes are chosen automatically.
|
||||||
|
const qr = encodeText('https://robonen.dev', EccMap.M);
|
||||||
|
|
||||||
|
console.log(qr.size); // module count per side (21..177)
|
||||||
|
|
||||||
|
// Render however you like — here, plain text:
|
||||||
|
let out = '';
|
||||||
|
for (let y = 0; y < qr.size; y++) {
|
||||||
|
for (let x = 0; x < qr.size; x++)
|
||||||
|
out += qr.getModule(x, y) ? '##' : ' ';
|
||||||
|
out += '\\n';
|
||||||
|
}
|
||||||
|
console.log(out);`
|
||||||
|
|
||||||
|
const reedSolomon = `import { computeDivisor, computeRemainder } from '@robonen/encoding';
|
||||||
|
|
||||||
|
// Build a degree-10 generator polynomial over GF(2^8/0x11D)...
|
||||||
|
const divisor = computeDivisor(10);
|
||||||
|
|
||||||
|
// ...then derive the 10 error-correction codewords for your data block.
|
||||||
|
const data = [0x40, 0xd2, 0x75, 0x47, 0x76, 0x17, 0x32, 0x06, 0x27, 0x26];
|
||||||
|
const ecc = computeRemainder(data, divisor); // Uint8Array(10)`
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Landing hero for @robonen/fetch — static, SSR-safe content.
|
||||||
|
const quickStart = `import { $fetch } from '@robonen/fetch';
|
||||||
|
|
||||||
|
interface Todo {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET + automatic JSON parse — the body is typed for you
|
||||||
|
const todo = await $fetch<Todo>('https://api.example.com/todos/1');
|
||||||
|
|
||||||
|
// POST a plain object — serialized to JSON, content-type set automatically
|
||||||
|
const created = await $fetch<Todo>('https://api.example.com/todos', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { title: 'Ship it', done: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Method shortcuts
|
||||||
|
await $fetch.get<Todo>('https://api.example.com/todos/1');
|
||||||
|
await $fetch.delete('https://api.example.com/todos/1');`;
|
||||||
|
|
||||||
|
const instances = `import { $fetch } from '@robonen/fetch';
|
||||||
|
|
||||||
|
// Derive a pre-configured instance — defaults & plugins are inherited
|
||||||
|
const api = $fetch.create({
|
||||||
|
baseURL: 'https://api.example.com/v1',
|
||||||
|
headers: { 'x-app': 'web' },
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await api('/users'); // → /v1/users, retry:2, x-app header
|
||||||
|
await api('/search', { query: { q: 'vue', page: 2 } });
|
||||||
|
|
||||||
|
// 'extend' layers on more defaults / plugins; child wins on conflicts
|
||||||
|
const billing = api.extend({ baseURL: 'https://billing.example.com' });`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>@robonen/fetch</h1>
|
||||||
|
<p>
|
||||||
|
A lightweight, type-safe <code>fetch</code> wrapper with interceptors, retry,
|
||||||
|
timeout, and a composable plugin system — V8-optimized internals, zero runtime
|
||||||
|
dependencies beyond the standard library.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
<code>globalThis.fetch</code> is great primitive plumbing, but every app
|
||||||
|
re-implements the same layer on top of it: JSON parsing, throwing on
|
||||||
|
<code>4xx</code>/<code>5xx</code>, base URLs, query strings, retries, timeouts,
|
||||||
|
auth headers. <strong>@robonen/fetch</strong> is that layer — small, fully typed,
|
||||||
|
and built so attaching features costs nothing on the hot path.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg)">Type-safe end to end</h3>
|
||||||
|
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||||
|
Response data, request options, and plugin-contributed fields are all inferred —
|
||||||
|
the parsed body comes back typed, no casting required.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg)">Smart bodies & parsing</h3>
|
||||||
|
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||||
|
Plain objects are JSON-serialized; <code>FormData</code>/<code>Blob</code>/streams
|
||||||
|
pass through untouched. Responses are decoded from <code>Content-Type</code> or
|
||||||
|
forced via <code>responseType</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg)">Retry, timeout & errors</h3>
|
||||||
|
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||||
|
Built-in retry and per-attempt timeout with sensible defaults, and non-2xx
|
||||||
|
responses reject with a rich <code>FetchError</code> carrying status, request,
|
||||||
|
and parsed body.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg)">Hooks & plugins</h3>
|
||||||
|
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||||
|
Lifecycle hooks plus a typed, composable plugin system with onion-style
|
||||||
|
<code>execute</code> middleware — composed once, with zero per-request overhead
|
||||||
|
beyond the hooks themselves.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Install</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="`pnpm add @robonen/fetch`" lang="bash" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Quick start</h2>
|
||||||
|
<p>
|
||||||
|
Import the default <code>$fetch</code> instance — it is backed by
|
||||||
|
<code>globalThis.fetch</code> and ready to use. The first type parameter types the
|
||||||
|
parsed body.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="quickStart" lang="ts" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Configured instances</h2>
|
||||||
|
<p>
|
||||||
|
Use <code>create</code> (or its alias <code>extend</code>) to derive instances with
|
||||||
|
a <code>baseURL</code>, default headers, retry policy, and plugins. Configuration is
|
||||||
|
merged down the chain; the child wins on conflicts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="instances" lang="ts" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Where to next</h2>
|
||||||
|
<p>
|
||||||
|
The full API reference is listed below. A few good places to start:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/fetch/create-fetch"><code>createFetch</code></NuxtLink> — build a
|
||||||
|
fully configured instance with defaults and plugins.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/fetch/define-plugin"><code>definePlugin</code></NuxtLink> — bundle
|
||||||
|
defaults, typed options, hooks, and <code>execute</code> middleware into a
|
||||||
|
reusable plugin.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/fetch/fetch-error"><code>FetchError</code></NuxtLink> — the rich
|
||||||
|
error thrown on non-2xx responses.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/fetch/build-url"><code>buildURL</code></NuxtLink> and
|
||||||
|
<NuxtLink to="/fetch/detect-response-type"><code>detectResponseType</code></NuxtLink>
|
||||||
|
— the URL and response-type helpers used internally, exported for reuse.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Landing hero for @robonen/platform. Static content only — no runtime APIs are
|
||||||
|
// touched at setup top-level, so this prerenders and hydrates safely.
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>@robonen/platform</h1>
|
||||||
|
<p>
|
||||||
|
Platform-dependent utilities for browser and multi-runtime JavaScript — focus management,
|
||||||
|
ARIA isolation, animation lifecycle tracking, and environment-safe globals.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Most utility libraries stop at the platform boundary: the moment you need to reach for the
|
||||||
|
DOM, shadow roots, <code>aria-hidden</code>, or <code>globalThis</code>, you are on your own.
|
||||||
|
<strong>@robonen/platform</strong> fills that gap. It packages the gritty, well-tested
|
||||||
|
primitives that overlays, dialogs, and editors depend on — focus guards, tabbable-edge
|
||||||
|
detection, sibling hiding for screen readers, and CSS animation settling — and ships them
|
||||||
|
SSR-aware and dependency-free. It is the low-level layer that powers
|
||||||
|
<NuxtLink to="/primitives">@robonen/primitives</NuxtLink> and the editor.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg)">
|
||||||
|
Focus, done right
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||||
|
Shadow-DOM-aware active-element lookup, scroll-free focusing, and first/last tabbable-edge
|
||||||
|
detection via a fast <code class="text-(--accent-text)">TreeWalker</code> — the bones of any focus trap.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg)">
|
||||||
|
Accessible isolation
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||||
|
<code class="text-(--accent-text)">hideOthers</code> marks every sibling
|
||||||
|
<code class="text-(--accent-text)">aria-hidden</code>, ref-counted across layers, preserving
|
||||||
|
<code class="text-(--accent-text)">aria-live</code> regions. A dependency-free port of <code>aria-hidden</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg)">
|
||||||
|
Animation lifecycle
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||||
|
Detect running animations and transitions, then settle exit animations cleanly with
|
||||||
|
fill-mode flash prevention — so unmounts wait for the CSS to finish.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg)">
|
||||||
|
Multi-runtime safe
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1.5 text-sm text-(--fg-muted)">
|
||||||
|
A resolved <code class="text-(--accent-text)">_global</code> and an
|
||||||
|
<code class="text-(--accent-text)">isClient</code> flag that work across Node, Bun, Deno, and the
|
||||||
|
browser — guards baked in so SSR never throws.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Install</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="`pnpm add @robonen/platform`" lang="bash" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Subpath exports</h2>
|
||||||
|
<p>
|
||||||
|
The package splits along the platform boundary. Browser-only helpers live under
|
||||||
|
<code>/browsers</code>; runtime-agnostic helpers live under <code>/multi</code>.
|
||||||
|
</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Entry</th>
|
||||||
|
<th>Scope</th>
|
||||||
|
<th>What you get</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>@robonen/platform/browsers</code></td>
|
||||||
|
<td>DOM</td>
|
||||||
|
<td>Focus, tabbable edges, <code>hideOthers</code>, animation lifecycle</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>@robonen/platform/multi</code></td>
|
||||||
|
<td>Any runtime</td>
|
||||||
|
<td><code>_global</code>, <code>isClient</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Usage</h2>
|
||||||
|
<p>
|
||||||
|
A typical overlay flow: capture the focused element, hide siblings from assistive tech, drop
|
||||||
|
focus onto the first tabbable target, and tear it all down on close.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode lang="ts" :code="`import {
|
||||||
|
getActiveElement,
|
||||||
|
getTabbableEdges,
|
||||||
|
focus,
|
||||||
|
hideOthers,
|
||||||
|
} from '@robonen/platform/browsers';
|
||||||
|
|
||||||
|
function openDialog(dialog: HTMLElement) {
|
||||||
|
// Remember where focus was, so we can restore it on close.
|
||||||
|
const previouslyFocused = getActiveElement();
|
||||||
|
|
||||||
|
// Hide everything outside the dialog from screen readers (ref-counted).
|
||||||
|
const undoHide = hideOthers(dialog);
|
||||||
|
|
||||||
|
// Move focus to the first tabbable element inside the dialog.
|
||||||
|
const { first } = getTabbableEdges(dialog);
|
||||||
|
focus(first, { select: true });
|
||||||
|
|
||||||
|
return function close() {
|
||||||
|
undoHide();
|
||||||
|
focus(previouslyFocused);
|
||||||
|
};
|
||||||
|
}`" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
On the cross-runtime side, reach for a safe global and a reliable client check without
|
||||||
|
sprinkling <code>typeof window</code> guards through your code:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode lang="ts" :code="`import { _global, isClient } from '@robonen/platform/multi';
|
||||||
|
|
||||||
|
// Works in Node, Bun, Deno and the browser — never throws in SSR.
|
||||||
|
if (isClient) {
|
||||||
|
_global.addEventListener('resize', onResize);
|
||||||
|
}`" />
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-subtle) p-4 text-sm text-(--fg-muted)">
|
||||||
|
<strong class="text-(--fg)">SSR note:</strong> browser helpers touch the DOM, so call them
|
||||||
|
inside event handlers or after mount.
|
||||||
|
<code class="text-(--accent-text)">hideOthers</code> already no-ops when <code>document</code> is
|
||||||
|
undefined, and <code>/multi</code> is import-safe everywhere.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Where to next</h2>
|
||||||
|
<p>Browse the full API reference below, or jump straight to the building blocks:</p>
|
||||||
|
<ul>
|
||||||
|
<li><NuxtLink to="/platform/focus-guard">focusGuard</NuxtLink> — add boundary guards for predictable focus wrapping</li>
|
||||||
|
<li><NuxtLink to="/platform/get-tabbable-edges">getTabbableEdges</NuxtLink> — find the first and last focusable elements in a container</li>
|
||||||
|
<li><NuxtLink to="/platform/hide-others">hideOthers</NuxtLink> — isolate a subtree for assistive technology</li>
|
||||||
|
<li><NuxtLink to="/platform/on-animation-settle">onAnimationSettle</NuxtLink> — run a callback once an animation or transition finishes</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const installCode = `pnpm add @robonen/stdlib`;
|
||||||
|
|
||||||
|
const usageCode = `import { groupBy, unique, clamp, tryIt } from '@robonen/stdlib';
|
||||||
|
|
||||||
|
// Arrays — group records by a derived key
|
||||||
|
const byType = groupBy(
|
||||||
|
[{ type: 'a', v: 1 }, { type: 'b', v: 2 }, { type: 'a', v: 3 }],
|
||||||
|
item => item.type,
|
||||||
|
);
|
||||||
|
// => { a: [{ type: 'a', v: 1 }, { type: 'a', v: 3 }], b: [{ type: 'b', v: 2 }] }
|
||||||
|
|
||||||
|
// Arrays — dedupe in a single pass
|
||||||
|
unique([1, 1, 2, 3, 3]); // => [1, 2, 3]
|
||||||
|
|
||||||
|
// Math — keep a value inside a range
|
||||||
|
clamp(value, 0, 100);
|
||||||
|
|
||||||
|
// Async — error handling without forking control flow
|
||||||
|
const { error, data } = await tryIt(fetch)('/api/todos/1');
|
||||||
|
if (error) {
|
||||||
|
// handle the failure
|
||||||
|
} else {
|
||||||
|
// use data
|
||||||
|
}`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<!-- Hero -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>@robonen/stdlib</h1>
|
||||||
|
<p class="text-lg text-(--fg-muted)">
|
||||||
|
A platform-independent standard library of tools, utilities, and helpers for TypeScript —
|
||||||
|
arrays, async, math, data structures, and patterns, all tree-shakeable and fully typed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Every project ends up reimplementing the same handful of helpers: dedupe an array,
|
||||||
|
clamp a number, group records by a key, retry a flaky request. <code>@robonen/stdlib</code>
|
||||||
|
collects those building blocks into one cohesive, dependency-free package. It runs the same
|
||||||
|
in Node, the browser, and the edge — there are no platform globals, no polyfills, and no
|
||||||
|
runtime dependencies. Import only what you use; the rest is shaken out of your bundle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feature highlights -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Fully typed</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) leading-relaxed">
|
||||||
|
Generic signatures preserve element and key types end to end, so inference keeps
|
||||||
|
working through <code>groupBy</code>, <code>partition</code>, <code>zip</code>, and friends.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Zero dependencies</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) leading-relaxed">
|
||||||
|
No transitive dependencies and no platform assumptions. The same code runs in Node,
|
||||||
|
the browser, Deno, Bun, and edge runtimes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Tree-shakeable</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) leading-relaxed">
|
||||||
|
Each utility is a standalone export with no shared side effects. Import a single
|
||||||
|
function and ship only that function.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Batteries included</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) leading-relaxed">
|
||||||
|
Beyond array and math helpers you get data structures
|
||||||
|
(<code>Deque</code>, <code>BinaryHeap</code>, <code>LinkedList</code>) and patterns
|
||||||
|
(<code>StateMachine</code>, <code>PubSub</code>, <code>Command</code>).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Install -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Install</h2>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="installCode" lang="bash" />
|
||||||
|
|
||||||
|
<!-- Usage -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Quick start</h2>
|
||||||
|
<p>
|
||||||
|
Import named utilities directly from the package root. Each one is small, predictable,
|
||||||
|
and focused on a single job.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="usageCode" lang="ts" />
|
||||||
|
|
||||||
|
<!-- Modules overview -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>What's inside</h2>
|
||||||
|
<p>
|
||||||
|
The library is organized into focused modules. A few of the most-used building blocks:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Arrays</strong> — <code>groupBy</code>, <code>partition</code>,
|
||||||
|
<code>unique</code>, <code>zip</code>, <code>cluster</code>, <code>range</code>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Async</strong> — <code>tryIt</code>, <code>retry</code>, <code>pool</code>,
|
||||||
|
<code>sleep</code> for control flow that doesn't fight you.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Math</strong> — <code>clamp</code>, <code>lerp</code>, <code>remap</code>,
|
||||||
|
each with a BigInt variant.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Functions</strong> — <code>debounce</code>, <code>throttle</code>,
|
||||||
|
<code>memoize</code>, <code>once</code>, <code>compose</code>, <code>pipe</code>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Structs & patterns</strong> — <code>Deque</code>,
|
||||||
|
<code>PriorityQueue</code>, <code>StateMachine</code>, <code>PubSub</code> and more.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Where to next -->
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h2 class="text-base font-semibold text-(--fg) mb-2">Where to next</h2>
|
||||||
|
<p class="text-sm text-(--fg-muted) mb-3">
|
||||||
|
Browse the full API reference below, or jump straight to a popular utility:
|
||||||
|
</p>
|
||||||
|
<ul class="flex flex-wrap gap-2 m-0 p-0 list-none">
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/stdlib/group-by"
|
||||||
|
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg-subtle) px-3 py-1.5 text-sm text-(--accent-text) hover:bg-(--bg-inset) focus:ring-(--ring)"
|
||||||
|
>
|
||||||
|
groupBy
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/stdlib/try-it"
|
||||||
|
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg-subtle) px-3 py-1.5 text-sm text-(--accent-text) hover:bg-(--bg-inset) focus:ring-(--ring)"
|
||||||
|
>
|
||||||
|
tryIt
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/stdlib/retry"
|
||||||
|
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg-subtle) px-3 py-1.5 text-sm text-(--accent-text) hover:bg-(--bg-inset) focus:ring-(--ring)"
|
||||||
|
>
|
||||||
|
retry
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/stdlib/clamp"
|
||||||
|
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg-subtle) px-3 py-1.5 text-sm text-(--accent-text) hover:bg-(--bg-inset) focus:ring-(--ring)"
|
||||||
|
>
|
||||||
|
clamp
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink
|
||||||
|
to="/stdlib/debounce"
|
||||||
|
class="inline-flex items-center rounded-md border border-(--border) bg-(--bg-subtle) px-3 py-1.5 text-sm text-(--accent-text) hover:bg-(--bg-inset) focus:ring-(--ring)"
|
||||||
|
>
|
||||||
|
debounce
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Landing hero for @robonen/renovate. Static content only — no runtime APIs are
|
||||||
|
// touched at setup top-level, so this prerenders and hydrates safely.
|
||||||
|
|
||||||
|
const renovateJson = `{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["github>robonen/tools//infra/renovate/default.json"]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const overrideExample = `{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["github>robonen/tools//infra/renovate/default.json"],
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"automerge": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>@robonen/renovate</h1>
|
||||||
|
<p class="text-lg text-(--fg-muted)">
|
||||||
|
A shared Renovate configuration preset — one line in your
|
||||||
|
<code>renovate.json</code> and dependency updates run on autopilot.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Every repository ends up re-deriving the same Renovate setup: which update
|
||||||
|
types to auto-merge, when to schedule the noise, how to phrase commit
|
||||||
|
messages, who to assign reviews to. <code>@robonen/renovate</code> captures
|
||||||
|
those decisions once as a single <code>default.json</code> preset that any
|
||||||
|
repo can extend, so the policy lives in one place instead of being copied
|
||||||
|
and slowly drifting across projects.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">One-line adoption</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
Extend it via
|
||||||
|
<code>github>robonen/tools//infra/renovate/default.json</code>
|
||||||
|
— no copy-pasted config, and updates to the policy roll out to every
|
||||||
|
consumer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">Quiet by default</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
Non-major updates are grouped via <code>group:allNonMajor</code>, so you
|
||||||
|
review one consolidated PR instead of a wall of individual bumps.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">Hands-off minor & patch</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
Minor, patch, pin, and digest updates auto-approve and auto-merge on a
|
||||||
|
1–3 AM schedule — safe upgrades land overnight, majors still wait for you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-1.5">Conventional commits</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) m-0">
|
||||||
|
Every update commits as <code>chore</code> with a
|
||||||
|
<code>bump</code> range strategy, and reviews are assigned straight from
|
||||||
|
<code>CODEOWNERS</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Install</h2>
|
||||||
|
<p>
|
||||||
|
You don't import this package in code — Renovate reads it from the repo by
|
||||||
|
reference. Add the package to your workspace if you want the bundled
|
||||||
|
<code>renovate-config-validator</code> available locally:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="`pnpm add @robonen/renovate`" lang="bash" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Usage</h2>
|
||||||
|
<p>
|
||||||
|
Create (or edit) <code>renovate.json</code> in your repository root and
|
||||||
|
extend the preset:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="renovateJson" lang="json" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
That's the whole setup. To diverge from the shared policy, append your own
|
||||||
|
<code>packageRules</code> after the extend — for example, opt out of
|
||||||
|
auto-merging major upgrades:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="overrideExample" lang="json" />
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-4 text-sm text-(--fg-muted)">
|
||||||
|
<strong class="text-(--fg)">Tip:</strong> validate any config that extends
|
||||||
|
this preset with
|
||||||
|
<code class="text-(--accent-text)">renovate-config-validator</code> before
|
||||||
|
committing — the package ships it as a <code>test</code> script against
|
||||||
|
<code>default.json</code>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="font-medium text-(--fg) mb-2">Where to next</h3>
|
||||||
|
<ul class="text-sm text-(--fg-muted) space-y-1.5 list-disc pl-5 m-0">
|
||||||
|
<li>
|
||||||
|
Read the guide sections below for the full list of what the preset
|
||||||
|
enables — extended configs, schedules, and package rules.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
See the upstream
|
||||||
|
<a href="https://docs.renovatebot.com/" class="text-(--accent-text) hover:underline" target="_blank" rel="noreferrer">Renovate docs</a>
|
||||||
|
for the complete configuration schema you can layer on top.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const quickStart = `<script setup lang="ts">
|
||||||
|
import { createDefaultRegistry, createEditor, createEditorState, EditorRoot } from '@robonen/editor';
|
||||||
|
|
||||||
|
const registry = createDefaultRegistry();
|
||||||
|
const editor = createEditor({ state: createEditorState({ registry }) });
|
||||||
|
<\/script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<EditorRoot :editor="editor" autofocus class="editor" />
|
||||||
|
<\/template>`;
|
||||||
|
|
||||||
|
const composeSlots = `<EditorRoot :editor="editor" autofocus>
|
||||||
|
<EditorContent />
|
||||||
|
<EditorBubbleMenu /> <!-- formatting toolbar on selection -->
|
||||||
|
<EditorSlashMenu /> <!-- type \`/\` to insert blocks -->
|
||||||
|
</EditorRoot>`;
|
||||||
|
|
||||||
|
const commands = `import { setBlockType, toggleMark } from '@robonen/editor';
|
||||||
|
|
||||||
|
editor.command(toggleMark('bold'));
|
||||||
|
editor.command(setBlockType('heading', { level: 2 }));
|
||||||
|
|
||||||
|
// Called without a dispatch they run dry — perfect for
|
||||||
|
// computing disabled / active toolbar state.
|
||||||
|
const canBold = editor.command(toggleMark('bold'));`;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>@robonen/editor</h1>
|
||||||
|
<p>
|
||||||
|
A <strong>headless, block-based rich-text editor for Vue 3</strong> — in the spirit of
|
||||||
|
Tiptap / ProseMirror / Editor.js, but with a registry-driven schema and a
|
||||||
|
<strong>hand-built CRDT</strong> for collaboration (no Yjs / Loro / Automerge).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Most editors force a trade: the structured, block-first authoring of Editor.js, or the
|
||||||
|
document fidelity of ProseMirror where native cross-block selection and arrow navigation
|
||||||
|
just work. <code>@robonen/editor</code> takes the ProseMirror route — a single
|
||||||
|
<code>contenteditable</code> surface — and layers a modular block registry on top, so blocks
|
||||||
|
and inline marks are added without touching the core. The model, schema, state, commands and
|
||||||
|
keymap are entirely DOM-free and Vue-free; the Vue layer only renders and handles input.
|
||||||
|
Every edit is a step-based transaction with an exact inverse, which gives you real undo/redo
|
||||||
|
and — because the same steps drive the CRDT — conflict-free collaboration for free.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Headless by design</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
Ships behavior and DOM structure (<code class="text-(--accent-text)">data-block-*</code>
|
||||||
|
hooks), never styling. Bring your own CSS and own the look completely.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Registry-driven schema</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
<code class="text-(--accent-text)">defineBlock</code> /
|
||||||
|
<code class="text-(--accent-text)">defineMark</code> register into an immutable schema —
|
||||||
|
add a custom block or mark with no core changes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Step-based transactions</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
Every edit is a step with an exact inverse, powering reliable undo/redo and a single source
|
||||||
|
of truth for both local edits and sync.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="mb-1.5 text-sm font-semibold text-(--fg)">Own CRDT, pluggable</h3>
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
RGA text, fractional-indexed blocks, Peritext-style marks and presence behind a
|
||||||
|
<code class="text-(--accent-text)">CrdtProvider</code> — over any transport.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Install</h2>
|
||||||
|
<p>
|
||||||
|
The editor depends on <code>@robonen/crdt</code> for the built-in collaboration provider, and
|
||||||
|
on <code>vue</code> as a peer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="`pnpm add @robonen/editor @robonen/crdt vue`" lang="bash" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Quick start</h2>
|
||||||
|
<p>
|
||||||
|
Create a registry, build an editor around its state, and mount <code>EditorRoot</code>. Its
|
||||||
|
default slot renders <code>EditorContent</code> (the single <code>contenteditable</code>), so
|
||||||
|
this is a fully working editor with all built-in blocks and marks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="quickStart" lang="vue" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Provide your own slot to add UI around the editable surface — the bubble toolbar floats over a
|
||||||
|
selection, and the slash menu opens when you type <code>/</code> at the start of a line.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="composeSlots" lang="vue" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Commands</h2>
|
||||||
|
<p>
|
||||||
|
Commands are <code>(state, dispatch?, view?) => boolean</code> functions that power the
|
||||||
|
keymap, the UI, and programmatic edits. Run one with <code>editor.command(...)</code>; omit
|
||||||
|
the dispatch to dry-run it for active/disabled state.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="commands" lang="ts" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Built-in blocks & marks</h2>
|
||||||
|
<p>
|
||||||
|
<code>createDefaultRegistry()</code> wires up a full set out of the box —
|
||||||
|
<strong>blocks:</strong> <code>paragraph</code>, <code>heading</code> (1–6),
|
||||||
|
<code>bulleted-list</code> / <code>numbered-list</code> / <code>todo-list</code>,
|
||||||
|
<code>blockquote</code>, <code>code-block</code>, <code>callout</code>, <code>divider</code>,
|
||||||
|
<code>image</code>; <strong>marks:</strong> <code>bold</code>, <code>italic</code>,
|
||||||
|
<code>underline</code>, <code>strike</code>, <code>highlight</code>, <code>code</code>,
|
||||||
|
<code>link</code>. Markdown input rules (<code># </code>, <code>- </code>, <code>1. </code>,
|
||||||
|
<code>> </code>, <code>[] </code>) and hotkeys (<code>Mod-b/i/u</code>,
|
||||||
|
<code>Mod-z</code>, …) are included.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
|
||||||
|
<p class="text-sm leading-relaxed text-(--fg-muted)">
|
||||||
|
<strong class="text-amber-700 dark:text-amber-400">Status: v0, work in progress.</strong>
|
||||||
|
Core logic is covered by unit + convergence tests; the contenteditable / Playwright suite
|
||||||
|
runs locally. The collaboration layer has a few documented, deferred limitations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Where to next</h2>
|
||||||
|
<p>Jump into the pieces you'll reach for first:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<code>EditorRoot</code> and <code>EditorContent</code> — the mount
|
||||||
|
surface and the single contenteditable.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/editor/create-default-registry"><code>createDefaultRegistry</code></NuxtLink>,
|
||||||
|
<NuxtLink to="/editor/define-block"><code>defineBlock</code></NuxtLink> and
|
||||||
|
<NuxtLink to="/editor/define-mark"><code>defineMark</code></NuxtLink> — extend the schema.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/editor/toggle-mark"><code>toggleMark</code></NuxtLink> /
|
||||||
|
<NuxtLink to="/editor/set-block-type"><code>setBlockType</code></NuxtLink> — the commands
|
||||||
|
API for programmatic and toolbar edits.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/editor/bind-crdt"><code>bindCrdt</code></NuxtLink> and
|
||||||
|
<NuxtLink to="/editor/create-native-provider"><code>createNativeProvider</code></NuxtLink>
|
||||||
|
— wire up real-time collaboration with the built-in CRDT.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
The full API reference for every export is listed right below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Landing hero for @robonen/primitives. Static content only — no runtime
|
||||||
|
// logic at setup top-level, so it prerenders and hydrates cleanly.
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>@robonen/primitives</h1>
|
||||||
|
<p>
|
||||||
|
A collection of unstyled, accessible UI primitives for Vue 3 — the headless
|
||||||
|
building blocks for design systems and component libraries.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="prose-docs">
|
||||||
|
Most component libraries bundle behavior and styling together, so the moment
|
||||||
|
your design diverges you end up fighting the framework. <code>@robonen/primitives</code>
|
||||||
|
ships the hard part — state, focus management, keyboard interaction, ARIA wiring,
|
||||||
|
portalling and positioning — and leaves the markup and styling entirely to you.
|
||||||
|
Every primitive is composed from small, controllable parts (a <code>Root</code>,
|
||||||
|
a <code>Trigger</code>, a <code>Content</code>, and so on) following the same
|
||||||
|
conventions, so once you learn one you know them all.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="m-0 text-sm font-semibold text-(--fg)">Unstyled by design</h3>
|
||||||
|
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
|
||||||
|
No CSS shipped. Primitives render the DOM you ask for and expose state via
|
||||||
|
data attributes, so you bring your own styles — Tailwind, vanilla CSS, anything.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="m-0 text-sm font-semibold text-(--fg)">Accessible out of the box</h3>
|
||||||
|
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
|
||||||
|
Focus scopes, roving tabindex, visually-hidden labels and correct ARIA roles
|
||||||
|
are handled for you. The suite is tested against
|
||||||
|
<code>axe-core</code> in a real browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="m-0 text-sm font-semibold text-(--fg)">Controlled or uncontrolled</h3>
|
||||||
|
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
|
||||||
|
Bind state with <code>v-model</code> when you need control, or set a
|
||||||
|
<code>defaultValue</code> / <code>defaultOpen</code> and let the primitive
|
||||||
|
manage itself.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-5">
|
||||||
|
<h3 class="m-0 text-sm font-semibold text-(--fg)">Composable & polymorphic</h3>
|
||||||
|
<p class="mt-2 mb-0 text-sm text-(--fg-muted)">
|
||||||
|
Every part takes an <code>as</code> prop, or use <code>as="template"</code>
|
||||||
|
to merge behavior onto your own element. Floating UI powers positioning for
|
||||||
|
popovers, tooltips and menus.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Install</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode :code="`pnpm add @robonen/primitives`" lang="bash" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Usage</h2>
|
||||||
|
<p>
|
||||||
|
Primitives are assembled from named parts. Here is a complete dialog — open
|
||||||
|
state is uncontrolled, focus is trapped, body scroll is locked, and the
|
||||||
|
content is portalled out of the DOM flow:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode lang="vue" :code="`<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
DialogRoot,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogClose,
|
||||||
|
} from '@robonen/primitives';
|
||||||
|
</scr­ipt>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot>
|
||||||
|
<DialogTrigger class="btn">Open</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay class="overlay" />
|
||||||
|
<DialogContent class="dialog">
|
||||||
|
<DialogTitle>Delete project</DialogTitle>
|
||||||
|
<DialogDescription>This action cannot be undone.</DialogDescription>
|
||||||
|
<DialogClose class="btn">Cancel</DialogClose>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</DialogRoot>
|
||||||
|
</template>`" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Need full control over open state? Bind it directly — the same primitive works
|
||||||
|
either way:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode lang="vue" :code="`<DialogRoot v-model:open="isOpen">
|
||||||
|
<!-- ... -->
|
||||||
|
</DialogRoot>`" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>The Primitive component</h2>
|
||||||
|
<p>
|
||||||
|
At the core of every part is <code>Primitive</code>, a polymorphic functional
|
||||||
|
component. Pass <code>as</code> to choose the element, or <code>as="template"</code>
|
||||||
|
to forward behavior onto a child of your own.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DocsCode lang="ts" :code="`import { Primitive, Slot } from '@robonen/primitives';
|
||||||
|
|
||||||
|
// <Primitive as="button" /> renders a <button>
|
||||||
|
// <Primitive as="template"> merges props onto the slotted child`" />
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Where to next</h2>
|
||||||
|
<p>
|
||||||
|
The full primitive index is listed below. A few good starting points:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><NuxtLink to="/primitives/dialog">Dialog</NuxtLink> and <NuxtLink to="/primitives/alert-dialog">Alert Dialog</NuxtLink> — modal layers with focus trapping.</li>
|
||||||
|
<li><NuxtLink to="/primitives/popover">Popover</NuxtLink>, <NuxtLink to="/primitives/tooltip">Tooltip</NuxtLink> and <NuxtLink to="/primitives/hover-card">Hover Card</NuxtLink> — Floating UI positioned surfaces.</li>
|
||||||
|
<li><NuxtLink to="/primitives/select">Select</NuxtLink>, <NuxtLink to="/primitives/combobox">Combobox</NuxtLink> and <NuxtLink to="/primitives/listbox">Listbox</NuxtLink> — keyboard-driven option pickers.</li>
|
||||||
|
<li><NuxtLink to="/primitives/switch">Switch</NuxtLink>, <NuxtLink to="/primitives/checkbox">Checkbox</NuxtLink> and <NuxtLink to="/primitives/slider">Slider</NuxtLink> — form controls that integrate with native inputs.</li>
|
||||||
|
<li><NuxtLink to="/primitives/focus-scope">Focus Scope</NuxtLink> and <NuxtLink to="/primitives/presence">Presence</NuxtLink> — the shared foundations every part builds on.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useCounter } from '../src';
|
||||||
|
|
||||||
|
const { count, increment, decrement, reset } = useCounter(0, { min: 0, max: 10 });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="docs-section">
|
||||||
|
<!-- Hero -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h1>@robonen/vue</h1>
|
||||||
|
<p>
|
||||||
|
A collection of <strong>213+ tree-shakeable, SSR-safe composables</strong> for Vue 3 —
|
||||||
|
reactive primitives for state, sensors, the DOM, browser APIs, animation, forms and more.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>
|
||||||
|
Every Vue app ends up re-implementing the same building blocks: a toggle, a debounced ref,
|
||||||
|
an event listener that cleans itself up, a media query, local-storage state. @robonen/vue
|
||||||
|
ships those building blocks as small, composable functions with a consistent API. Each one
|
||||||
|
is independently tree-shakeable, written in TypeScript with full inference, and safe to call
|
||||||
|
during server-side rendering — guards for <code>window</code>, <code>document</code> and
|
||||||
|
<code>navigator</code> are built in, so the same code runs on the server and hydrates cleanly
|
||||||
|
on the client.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Feature highlights -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Tree-shakeable by design</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) leading-relaxed">
|
||||||
|
Import only what you use. Each composable lives on its own and pulls in nothing it
|
||||||
|
doesn't need — your bundle stays exactly as small as your usage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">SSR-safe out of the box</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) leading-relaxed">
|
||||||
|
Browser-only access is guarded behind lifecycle hooks and configurable
|
||||||
|
<code>window</code>/<code>document</code> targets, so Nuxt and SSR setups just work.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Fully typed</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) leading-relaxed">
|
||||||
|
Written in TypeScript with precise return types and generics. <code>MaybeRefOrGetter</code>
|
||||||
|
arguments mean you can pass plain values, refs or getters interchangeably.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-subtle) p-5">
|
||||||
|
<h3 class="text-sm font-semibold text-(--fg) mb-1.5">Broad coverage</h3>
|
||||||
|
<p class="text-sm text-(--fg-muted) leading-relaxed">
|
||||||
|
From state and reactivity to sensors, elements, storage, math and form handling —
|
||||||
|
one cohesive toolkit spanning the whole surface of a Vue app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Install -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Install</h2>
|
||||||
|
</div>
|
||||||
|
<DocsCode :code="`pnpm add @robonen/vue`" lang="bash" />
|
||||||
|
|
||||||
|
<!-- Usage -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Quick start</h2>
|
||||||
|
<p>
|
||||||
|
Import the composables you need and use them inside <code><script setup></code>.
|
||||||
|
Here's a counter clamped to a range, with auto-cleaning keyboard shortcuts:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DocsCode
|
||||||
|
:code="`import { useCounter, useEventListener, useToggle } from '@robonen/vue';
|
||||||
|
|
||||||
|
// Clamped, reactive counter
|
||||||
|
const { count, increment, decrement, reset } = useCounter(0, { min: 0, max: 10 });
|
||||||
|
|
||||||
|
// A boolean toggle with custom truthy/falsy values
|
||||||
|
const { value: theme, toggle } = useToggle('light', {
|
||||||
|
truthyValue: 'dark',
|
||||||
|
falsyValue: 'light',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listener is removed automatically on unmount
|
||||||
|
useEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'ArrowUp') increment();
|
||||||
|
if (e.key === 'ArrowDown') decrement();
|
||||||
|
});`"
|
||||||
|
lang="ts"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Live demo -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<p>The same <code>useCounter</code> running live:</p>
|
||||||
|
</div>
|
||||||
|
<ClientOnly>
|
||||||
|
<div class="flex items-center gap-3 rounded-lg border border-(--border) bg-(--bg-subtle) p-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="size-9 rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring) disabled:opacity-40"
|
||||||
|
:disabled="count <= 0"
|
||||||
|
@click="decrement()"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span class="min-w-12 text-center text-lg font-medium tabular-nums text-(--fg)">{{ count }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="size-9 rounded-md border border-(--border) bg-(--bg-elevated) text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring) disabled:opacity-40"
|
||||||
|
:disabled="count >= 10"
|
||||||
|
@click="increment()"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-auto rounded-md px-3 py-1.5 text-sm text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset) focus:outline-none focus:ring-2 focus:ring-(--ring)"
|
||||||
|
@click="reset()"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
<!-- Where to next -->
|
||||||
|
<div class="prose-docs">
|
||||||
|
<h2>Where to next</h2>
|
||||||
|
<p>
|
||||||
|
The full API reference is listed right below. A few good starting points:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/vue/use-counter">useCounter</NuxtLink> — a clamped, reactive counter
|
||||||
|
with increment / decrement / set / reset.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/vue/use-toggle">useToggle</NuxtLink> — a boolean toggle with
|
||||||
|
customizable truthy / falsy values.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/vue/use-event-listener">useEventListener</NuxtLink> — declarative
|
||||||
|
event listeners that clean up on unmount.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/vue/use-storage">useStorage</NuxtLink> — reactive state synced to
|
||||||
|
<code>localStorage</code> / <code>sessionStorage</code>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/vue/use-magic-keys">useMagicKeys</NuxtLink> — reactive keyboard
|
||||||
|
state for building shortcuts.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user