diff --git a/.github/workflows/ci.yaml b/.gitea/workflows/ci.yaml similarity index 96% rename from .github/workflows/ci.yaml rename to .gitea/workflows/ci.yaml index 488aaaa..5e20f96 100644 --- a/.github/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -77,7 +77,7 @@ jobs: # browser. playwright is a direct devDep of these packages, so run its CLI # in the package context (--filter) — it isn't resolvable from the root. - name: Install Playwright Chromium - if: matrix.package == '@robonen/primitives' || matrix.package == '@robonen/editor' + if: matrix.package == '@robonen/primitives' || matrix.package == '@robonen/writekit' run: pnpm --filter "${{ matrix.package }}" exec playwright install --with-deps chromium - name: Lint @@ -87,7 +87,7 @@ jobs: run: pnpm --filter "${{ matrix.package }}" --if-present run test # Sentinel job — aggregates all matrix results into a single status check. - # Add "CI" as the required check in branch protection rules. + # Add "CI" as the required status check in the branch protection rules. ci: name: CI needs: check diff --git a/.github/workflows/publish.yaml b/.gitea/workflows/publish.yaml similarity index 96% rename from .github/workflows/publish.yaml rename to .gitea/workflows/publish.yaml index 931f9e7..c11672a 100644 --- a/.github/workflows/publish.yaml +++ b/.gitea/workflows/publish.yaml @@ -43,31 +43,31 @@ jobs: run: | # Find all package.json files (excluding node_modules) PACKAGE_FILES=$(find . -path "*/package.json" -not -path "*/node_modules/*") - + for file in $PACKAGE_FILES; do PACKAGE_DIR=$(dirname $file) echo "Checking $PACKAGE_DIR for version changes..." - + # Get package details PACKAGE_NAME=$(node -p "require('$file').name") CURRENT_VERSION=$(node -p "require('$file').version") IS_PRIVATE=$(node -p "require('$file').private || false") - + # Skip private packages if [ "$IS_PRIVATE" == "true" ]; then echo "Skipping private package $PACKAGE_NAME" continue fi - + # Skip root package if [ "$PACKAGE_DIR" == "." ]; then echo "Skipping root package" continue fi - + # Check if package exists on npm NPM_VERSION=$(npm view $PACKAGE_NAME version 2>/dev/null || echo "0.0.0") - + # Compare versions if [ "$CURRENT_VERSION" != "$NPM_VERSION" ]; then echo "Version changed for $PACKAGE_NAME: $NPM_VERSION → $CURRENT_VERSION" diff --git a/configs/eslint/src/index.ts b/configs/eslint/src/index.ts index 498a123..1786d35 100644 --- a/configs/eslint/src/index.ts +++ b/configs/eslint/src/index.ts @@ -2,7 +2,7 @@ export { compose } from './compose'; /* Presets */ -export { base, ignores, typescript, vue, vitest, imports, node, stylistic, regexp } from './presets'; +export { base, ignores, typescript, vue, vitest, tests, imports, node, stylistic, regexp } from './presets'; /* Types */ export type { diff --git a/configs/eslint/src/presets/base.ts b/configs/eslint/src/presets/base.ts index 4da972a..ae8479e 100644 --- a/configs/eslint/src/presets/base.ts +++ b/configs/eslint/src/presets/base.ts @@ -62,7 +62,10 @@ export const base: FlatConfigArray = [ rules: { /* ── eslint core ──────────────────────────────────────── */ eqeqeq: 'error', - 'no-console': 'warn', + /* Allow intentional `console.warn`/`console.error` — used for library dev + diagnostics (a11y/validation warnings, often `__DEV__`-guarded). Stray + `console.log`/`debug`/`info` are still flagged. */ + 'no-console': ['warn', { allow: ['warn', 'error'] }], 'no-debugger': 'error', 'no-eval': 'error', 'no-var': 'error', diff --git a/configs/eslint/src/presets/index.ts b/configs/eslint/src/presets/index.ts index dd29f3e..830dbb6 100644 --- a/configs/eslint/src/presets/index.ts +++ b/configs/eslint/src/presets/index.ts @@ -2,6 +2,7 @@ export { base, ignores } from './base'; export { typescript } from './typescript'; export { vue } from './vue'; export { vitest } from './vitest'; +export { tests } from './tests'; export { imports } from './imports'; export { node } from './node'; export { regexp } from './regexp'; diff --git a/configs/eslint/src/presets/tests.ts b/configs/eslint/src/presets/tests.ts new file mode 100644 index 0000000..9742dcd --- /dev/null +++ b/configs/eslint/src/presets/tests.ts @@ -0,0 +1,39 @@ +import type { FlatConfigArray } from '../types'; + +/** + * Relaxations for test, spec and benchmark files — the type-boundary carve-outs + * that test scaffolding legitimately needs, applied uniformly across every + * package. + * + * Tests stub globals (`(globalThis as any).x`), cast `vi.fn()` mocks, build + * throwaway fixtures and keep deliberate sink variables; benchmarks pre-size + * arrays with `new Array(n)`. The `vitest` preset already grants these for its + * own (`it`/`expect`) ruleset, but most packages don't adopt that preset (their + * tests use string `describe` titles), so this small overlay carries just the + * relaxations — no vitest-specific style rules — and is meant to be composed + * LAST so it wins over the `typescript`/`stylistic` presets for these files. + * + * Source `any` is unaffected: it stays at `warn` everywhere else. + */ +export const tests: FlatConfigArray = [ + { + name: 'robonen/tests', + files: [ + '**/*.{test,spec,bench}.{ts,tsx,cts,mts,js,jsx,cjs,mjs}', + '**/test/**/*.{ts,tsx,js,jsx}', + '**/__test__/**/*.{ts,tsx,js,jsx}', + '**/__tests__/**/*.{ts,tsx,js,jsx}', + ], + rules: { + /* Test scaffolding inspects/stubs untyped boundaries; `any` is idiomatic here. */ + '@typescript-eslint/no-explicit-any': 'off', + /* Sink variables, partially-used fixtures and `_`-less throwaways are fine in tests. */ + '@typescript-eslint/no-unused-vars': 'off', + 'no-unused-vars': 'off', + /* Benchmarks legitimately pre-size arrays (`new Array(n)`) for fixtures. */ + 'unicorn/no-new-array': 'off', + /* Empty mock/fixture classes (e.g. stubbing `class DeviceOrientationEvent {}`). */ + '@typescript-eslint/no-extraneous-class': 'off', + }, + }, +]; diff --git a/core/crdt/README.md b/core/crdt/README.md index a9a61a2..e3215ab 100644 --- a/core/crdt/README.md +++ b/core/crdt/README.md @@ -1,6 +1,6 @@ # @robonen/crdt -Framework-agnostic CRDT primitives — the convergence engine behind `@robonen/editor`, usable on their own. Zero runtime dependencies; pure TypeScript; runs in Node and the browser. +Framework-agnostic CRDT primitives — the convergence engine behind `@robonen/writekit`, usable on their own. Zero runtime dependencies; pure TypeScript; runs in Node and the browser. Every primitive is built so that **applying the same set of operations in any order, with duplicates, yields the same state** (commutative, idempotent, convergent), verified by property tests. @@ -50,7 +50,7 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — converged - `compareOpId` is the single deterministic tie-break (higher clock wins; site id breaks ties) every primitive agrees on — that's what makes LWW and RGA converge. - `VersionVector` assumes **dense** per-site clocks (1, 2, 3, …). - The v1 wire format is JSON encoded to bytes — simple and debuggable; a compact varint format is a later optimization with no API change. -- An editor-specific composition of these primitives (blocks + text + marks ↔ editor steps) lives in `@robonen/editor` under `crdt/native/`, not here — this package stays domain-agnostic. +- A writekit-specific composition of these primitives (blocks + text + marks ↔ writekit steps) lives in `@robonen/writekit` under `crdt/native/`, not here — this package stays domain-agnostic. ## Development diff --git a/core/crdt/docs/01-concepts.vue b/core/crdt/docs/01-concepts.vue index 80c83cd..4878a4d 100644 --- a/core/crdt/docs/01-concepts.vue +++ b/core/crdt/docs/01-concepts.vue @@ -179,13 +179,13 @@ const propsSrc = `// Commutative — order of application doesn't matter: 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.

-
-

- Why one rule for everything? - LwwRegister uses - compareOpId to pick the surviving value; - Rga uses it to break ties between concurrent inserts at - the same position; MarkStore uses it to decide which +

+

+ Why one rule for everything? + LwwRegister uses + compareOpId to pick the surviving value; + Rga uses it to break ties between concurrent inserts at + the same position; MarkStore 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.

@@ -223,11 +223,11 @@ const propsSrc = `// Commutative — order of application doesn't matter:
-

+

Density matters. - VersionVector only works because clocks arrive without - gaps. If you generate ids with a raw LamportClock, deliver - them in order per site (the Replica's causal buffer does + VersionVector only works because clocks arrive without + gaps. If you generate ids with a raw LamportClock, deliver + them in order per site (the Replica'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.

@@ -242,23 +242,23 @@ const propsSrc = `// Commutative — order of application doesn't matter:
-
-

Commutative

-

+

+

Commutative

+

Order of application doesn't change the result. A replica can integrate operations as they arrive, in whatever sequence the network delivers them.

-
-

Idempotent

-

+

+

Idempotent

+

Applying the same operation twice is the same as applying it once. Redelivery and retries are safe; version vectors make them free.

-
-

Convergent

-

+

+

Convergent

+

Same set of operations, same final state — full stop. Two replicas that have seen the same ops are byte-for-byte identical.

diff --git a/core/crdt/docs/02-primitives.vue b/core/crdt/docs/02-primitives.vue index 77e29a4..d9b21f0 100644 --- a/core/crdt/docs/02-primitives.vue +++ b/core/crdt/docs/02-primitives.vue @@ -198,33 +198,33 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
-
-

Registers

-

- LwwRegister and - LwwMap — single values and keyed maps where the +

+

Registers

+

+ LwwRegister and + LwwMap — single values and keyed maps where the write with the highest op id wins.

-
-

Ordering

-

- keyBetween / - keysBetween — fractional indexing to place or move +

+

Ordering

+

+ keyBetween / + keysBetween — fractional indexing to place or move an item with a single string key.

-
-

Sequence

-

- Rga — a replicated growable array: an ordered +

+

Sequence

+

+ Rga — a replicated growable array: an ordered sequence CRDT with tombstones and a deterministic insert tie-break.

-
-

Marks

-

- MarkStore — lightweight Peritext formatting spans +

+

Marks

+

+ MarkStore — lightweight Peritext formatting spans anchored to character op ids, resolved per character by highest op id.

@@ -262,12 +262,12 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
-
-

- Why keep tombstones? If a delete simply dropped the entry, - a concurrent set arriving afterward would resurrect +

+

+ Why keep tombstones? If a delete simply dropped the entry, + a concurrent set arriving afterward would resurrect the key — the two replicas would disagree on whether it exists. Retaining the delete as a - timestamped tombstone lets compareOpId decide the + timestamped tombstone lets compareOpId decide the winner deterministically, the same way it does for live values.

@@ -308,9 +308,9 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;
-

+

Heads up: - keyBetween requires lower < upper + keyBetween requires lower < upper and throws otherwise. Two replicas independently generating a key between the same neighbors can produce identical keys; pair the key with the item's op id as a secondary sort to keep ordering deterministic, or let @@ -366,14 +366,14 @@ store.resolve(order).map(m => m.get('strong')); // [true, true, true, true]`;

-
-

- Garbage collection. Tombstones accumulate. When every - replica has fully synced and nothing is in flight, gc(stable, keep?) +

+

+ Garbage collection. Tombstones accumulate. When every + replica has fully synced and nothing is in flight, gc(stable, keep?) drops deleted nodes whose insert is covered by a stable VersionVector, 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 keep to protect ids still + integrate — and pass keep to protect ids still referenced elsewhere, such as mark span endpoints.

diff --git a/core/crdt/docs/03-replication.vue b/core/crdt/docs/03-replication.vue index 8dfb6d6..8ea9c1a 100644 --- a/core/crdt/docs/03-replication.vue +++ b/core/crdt/docs/03-replication.vue @@ -249,12 +249,12 @@ a.replica.receive(ops);`;
-
-

Why the order of the two deltas is irrelevant

-

- You could swap the two receive lines, run them +

+

Why the order of the two deltas is irrelevant

+

+ You could swap the two receive 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 compareOpId places + adds ops it hasn't seen, and compareOpId places each op in its deterministic position regardless of arrival order. That is convergence, and the property tests assert it across randomized schedules.

@@ -346,11 +346,11 @@ a.replica.receive(ops);`;

Dense clocks are a precondition

-

+

Version vectors assume each site's clocks are dense (1, 2, 3, …). That holds automatically - when ids come from Replica.nextId(). If you mint + when ids come from Replica.nextId(). If you mint ids yourself, never skip a value for a site — a gap would make - delta believe a missing op was already delivered. + delta believe a missing op was already delivered.

diff --git a/core/crdt/docs/04-playground.vue b/core/crdt/docs/04-playground.vue index 45b514f..1698448 100644 --- a/core/crdt/docs/04-playground.vue +++ b/core/crdt/docs/04-playground.vue @@ -260,17 +260,17 @@ a.rga.toArray().join('') === b.rga.toArray().join(''); // true — CONVERGED`; -
+
-

Spin up two fresh replicas to start editing.

+

Spin up two fresh replicas to start editing.

-

+

With box: 'border-box' the reported size includes padding, so the slider changes the numbers without resizing the element.

diff --git a/vue/toolkit/src/composables/elements/useElementVisibility/demo.vue b/vue/toolkit/src/composables/elements/useElementVisibility/demo.vue index f13ae2e..6476a79 100644 --- a/vue/toolkit/src/composables/elements/useElementVisibility/demo.vue +++ b/vue/toolkit/src/composables/elements/useElementVisibility/demo.vue @@ -22,16 +22,16 @@ watch(isVisible, (visible, was) => {