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