Skip to content

Repository files navigation

EveryConfig

One config API. Every format. Comments included.

A Jackson-backed configuration library for the JVM. Write your config code once against a small, typed, path-based API — then read and write it as YAML, JSON, TOML or JSONC without changing a line. Comments, key order and unknown keys survive a round-trip; typed entity binding is a derived view that merges into the data, never clobbers it.

Runtime Build Formats Version


Table of contents

📖 Full documentation lives on the Wiki. This README is the tour; the wiki has the per-topic deep dives, the API cheat sheet, and the gotchas.


Why

Most config libraries marry you to one format, and lose your comments the first time the app rewrites the file. EveryConfig treats the format as a deployment choice, not an architectural one, and the canonical state is a Jackson ObjectNode tree that every format reads into and writes out of.

  • 🌳 One tree, many formats. The canonical state is a Jackson ObjectNode. A pluggable Codec turns text ⇄ tree; swap new YamlCodec() for new TomlCodec() and the rest of your code is untouched.
  • 💬 Comments survive. A format-agnostic comment overlay round-trips block, side, header and footer comments — through YAML, TOML and JSONC. JSON declares no comment fidelity and never pretends to. The vertical layout survives too, and you can shape it: withBlankLineBeforeComments(1) breathes a documented file into paragraphs, and a \n opening a comment asks for blank lines at that one path.
  • 🧩 Typed binding — override or merge, your call. Bind the tree to a POJO when you want types. Writing a POJO is explicit: setValue replaces the subtree, mergeValue merges into it — with merge, unknown keys a user added by hand survive and the tree wins on conflict.
  • 🌱 Self-healing defaults. getOrSetValueIfAbsent(path, def, comment) seeds a value and its documentation only when absent, so it is safe to call on every startup.
  • 📐 Rules that mean something. A field can declare more than its type: @Min(0) @Max(100) on a percentage, @Explicit on a token an operator must fill in. Rules run inside the bind and report through the channel your @PostLoad already reads; the same declarations feed a settings screen with no Config in sight, and can write themselves into the file as comments.
  • 🛟 Corruption-proof startup. A malformed file is backed up to .bak and the config starts empty — a broken config never blocks boot.
  • ☕ Java 8 runtime. Bytecode targets Java 8 while the source is written in modern Java; the dependency set (Jackson) is Java-8-clean, so EveryConfig runs on a Java 8 JVM.

Supported formats

Format Codec Comment fidelity Extensions Notes
YAML YamlCodec LOSSLESS yml, yaml Block + side + header/footer comments round-trip.
JSON JsonCodec NONE json Strict RFC JSON; pretty-printed; comments are not emitted.
TOML TomlCodec LOSSLESS toml [table] sections + # comments. No null (omitted); see the gotchas.
JSONC JsoncCodec LOSSY jsonc JSON with // comments; best-effort comment positions.

Comment fidelity is a codec capability, not a global setting. Code that sets a comment is always safe to run on any codec — a NONE codec simply does not emit it, and never corrupts the data doing so.


Install

EveryConfig is a thin java-library jar: Jackson is a normal transitive dependency, not relocated. Two artifacts, released in lockstep:

Artifact What it is
everyconfig-core the whole library — tree, codecs, comment overlay, typed binding, and the rule seam with no rule in it
everyconfig-rules optional — the rule vocabulary: jakarta's constraints read natively, plus @Explicit/@OneOf/@Unique

Gradle

repositories {
    maven { url 'https://maven.petrus.dev/public' }
    mavenCentral()
}

dependencies {
    implementation 'br.com.finalcraft.everyconfig:everyconfig-core:1.1.0'
    implementation 'br.com.finalcraft.everyconfig:everyconfig-rules:1.1.0'   // optional
}

Maven

<repository>
  <id>petrus-public</id>
  <url>https://maven.petrus.dev/public</url>
</repository>

<dependency>
  <groupId>br.com.finalcraft.everyconfig</groupId>
  <artifactId>everyconfig-core</artifactId>
  <version>1.1.0</version>
</dependency>

Upgrading from 1.0.1? The old coordinate br.com.finalcraft:EveryConfig no longer exists. Because the coordinate CHANGED, dependency resolution will not deduplicate it — a build that still pulls it transitively ends up with duplicated classes. Remove or exclude it everywhere. See the CHANGELOG.

Bukkit/Spigot consumers: a plugin that bundles EveryConfig should run its own shadowJar and relocate com.fasterxml.jackson and org.yaml.snakeyaml in its shade step. Relocation policy belongs at the leaf artifact — the only place that knows the server's classpath — so the library itself ships thin.


Quick start

import br.com.finalcraft.everyconfig.config.Config;

Config cfg = Config.open("server.yml"); // codec chosen from the .yml extension (fail-fast if unknown)

cfg.setValue("server.host", "localhost");
int port = cfg.getOrSetValueIfAbsent("server.port", 25565, "the listen port"); // seeds value + comment if absent

cfg.save(); // atomic write; comments + key order preserved

Switching format is a one-line change — everything below Config.open(...) stays identical:

Config cfg = Config.open("server.toml"); // or pass a codec explicitly: Config.open(path, new TomlCodec())

Config.open accepts a String, File or Path and derives the codec from the file extension via the CodecRegistry — it never guesses, throwing a CodecException on a missing/unknown extension. Pass a codec explicitly (Config.open(path, codec)) to override.

Need types? Bind the tree to a POJO:

DbConfig db = cfg.getValue("database", DbConfig.class); // read the subtree at a path bound to the type
db.maxPool = 25;
cfg.setValue("database", db);                           // setValue REPLACES the subtree (mergeValue keeps unknown keys)
cfg.save();

Core concepts

Type Role
Config The handle: a thin wrapper over one canonical ObjectNode, plus the dynamic path API, the comment API and the file lifecycle.
Codec A pluggable format strategy (JsonCodec, YamlCodec, TomlCodec, JsoncCodec). Turns text ⇄ tree and declares its comment fidelity. One mapper is shared per codec across every live config.
CommentTree The format-agnostic comment overlay (block/side/header/footer + blank-line layout), captured on load and reconciled on save.
EntityBinder The typed view: binds the tree to a POJO and merges a POJO back into the tree.

The 5 design decisions (the DNA)

  1. The tree is canonical. The dynamic API operates on the ObjectNode; typed binding is a derived view. The typed binder and mergeValue merge into the tree (on conflict the tree wins, unknown keys survive); setValue replaces the subtree it targets.
  2. Comments are seed/override. @Comment (and getOrSetValueIfAbsent(...,comment)) write comments in two explicit modes — rewrite-every-save or write-once.
  3. Comment fidelity is a codec capability — each codec declares LOSSLESS / LOSSY / NONE.
  4. Save is reconciliation against the captured (data, comments, key order): file order is preserved, new keys are appended.
  5. The emitter renders structure itself and delegates only leaf-value serialization to the mapper — a custom mapper can restyle a value but cannot break the layout. The Codec (text⇄tree⇄entity) is separate from the I/O layer.

The dynamic API

Dot-separated paths over the canonical tree; typed getters route through one coercion seam.

cfg.setValue("a.b.c", 42);          // auto-vivifies intermediate objects
cfg.getInt("a.b.c");                // 42
cfg.getInt("missing", 7);           // 7 (default)
cfg.setValue("a.b.c", null);        // a Java null deletes the entry
cfg.removeValue("a.b");             // returns boolean; drops the subtree's comments too

cfg.contains("a");                  // true
cfg.getKeys("a");                   // direct children
cfg.getKeys("", true);             // deep, dotted descendant paths
cfg.getConfigSection("a.b");        // a scoped view that delegates back with the sub-path prefixed

cfg.migrateKey("old.name", "new.name"); // move a key (and its comments); returns a MigrationResult

cfg.pinLast("Debug");               // keep the "Debug" section at the bottom, even as new keys are seeded
cfg.pinFirst("meta");               // float a key to the top of its level; unpin("meta") clears it

Keys that contain a dot: . separates path segments, so a key that legitimately contains one is escapedcfg.getInt("rates.usd\\.brl") addresses the single key "usd.brl" under rates (and \\ is a literal backslash). The escape is a no-op for an ordinary key, so normal paths are unaffected.

migrateKey is startup-safe and observable. It returns a MigrationResult so a re-run that finds the data already moved (ALREADY_MIGRATED) is told apart from a typo'd source that never existed (SOURCE_ABSENT) — both no-ops, but only one is worth logging.

Pinning key order. pinFirst/pinLast keep a key at the top/bottom of its level on save (unpin clears it), so a Debug section stays last even as getOrSetValueIfAbsent seeds new keys over time. It is an in-memory ordering policy — re-assert it at startup, like @Comment — and best-effort per format: YAML/JSONC honor it fully, TOML honors it within the scalar group and within the table group (never moving a scalar past a table), and JSON's plain output keeps live-tree order.

Legacy long-as-string tolerance: the numeric getters parse a number stored as a quoted string, so a long once written as "1700000000000" still reads back via getLong. getUUID is equally tolerant — a malformed value yields null/the default rather than throwing.

Trichotomy: an absent path, an explicit null, and a real value are distinct — contains tells absent from present, and a typed getter flattens an explicit null to its default.

→ Deep dive: The Dynamic API


Default values & comments

getOrSetValueIfAbsent seeds on first run and lets the file win afterwards. Comments come in two write modes:

// seeds the value AND the comment only if the path is absent — safe on every startup
int port = cfg.getOrSetValueIfAbsent("server.port", 25565, "the listen port");

cfg.setComment("server.port", "ALWAYS overwritten on save");          // authoritative
cfg.setDefaultComment("server.port", "written only if absent");        // user-edited comment wins

On a class, @Comment defaults to OVERRIDE (documentation in code stays current), or @Comment(mode = SET_IF_ABSENT) to let a user's edit win.

The file header and footer (the comment blocks above the first key and below the last) have the same two write modes. A line may carry \n, so a multi-line banner goes in as one argument:

cfg.setHeader("=== My Plugin ===\nDo not edit while the server is running");  // OVERRIDE
cfg.setDefaultFooter("generated by EveryConfig");                              // only if absent
List<String> banner = cfg.getHeader();                                        // empty when none

A class-level @Comment still seeds the header (its mode decides override vs set-if-absent). The header never swallows the first key's own comment: the blank line separating them is the boundary, so emit them as distinct blocks. On a NONE-fidelity codec (JSON) header/footer are held in memory but never written.

Breathing room

A seeded file is dense by default — every comment glued to the entry above it. Two independent knobs open it up, and neither is written to the file as a setting:

cfg.withBlankLineBeforeComments(1);        // one blank line above every COMMENTED root key
cfg.withBlankLineBeforeComments(1, 2);     // ...reaching one level deeper

cfg.setComment("DebugMode", "\n\nDebug system.");   // this path alone: two blank lines above it
cfg.setBlankLinesBefore("DebugMode", 0);            // and this is how you take them away

The policy is a rendering policy, like a key pin: re-assert it at startup, it survives reload(), and it is off by default so a plain load/save stays byte-identical. It floors only commented entries, never the first entry of a block (the header or the parent key already separates), and never tightens spacing the file already had. The \n directive works at any depth and wins when it asks for more.

Once a save has emitted those blank lines they belong to the file — turning the policy off later does not take them back; setBlankLinesBefore(path, 0) does.

→ Deep dive: Default Values & Comments


Typed entity binding

Binding is a derived view over the tree. A binding save merges into the tree (the tree wins, unknown keys survive) — the same merge as mergeValue; setValue instead replaces the subtree:

@Comment(value = "Database settings", mode = CommentMode.SET_IF_ABSENT)
class DbConfig {
    @Comment("The JDBC url")
    @Key(transformCase = KeyTransformCase.KEBAB_CASE)
    String jdbcUrl = "jdbc:h2:mem:test";          // -> key "jdbc-url"

    @Section("database.pool")                       // a flat field placed under a nested path
    int maxSize = 50;

    @PostLoad
    void validate() { /* runs after binding */ }
}

DbConfig db = cfg.loadAs(DbConfig.class, codec);    // lenient by default
  • Path-oriented binder. cfg.bind(type[, codec]).read(path) / readInto(path, target) / write(path, pojo) (the root is the empty path ""); read*Result(...) variants carry the issues. The Config façade wraps it: getValue(path, type) (typed read), getValueInto(path, target), getList(path, type), and a POJO setValue(path, pojo) (annotation-aware override) / mergeValue(path, pojo) (annotation-aware merge).
  • Lenient bind (default): a value that can't be coerced is recorded as a LoadIssue and the field keeps its real default; STRICT throws a BindException on the first mismatch. Use loadAsResult(...) (or the binder's readResult(...)) to get a BindResult<T> carrying the value and the issues together.
  • Annotations: @Key (rename + case, or class-wide via @JsonNaming(KeyCaseStrategy.Kebab.class)), @Comment (+ CommentMode; seeded on top-level and nested-POJO fields, at the sub-path where the value lands), @Section (nested placement, on top-level or nested-POJO fields), @KeyIndex (collection indexing). Native Jackson annotations keep working too.
  • Lifecycle hooks: @PreLoad/@PostLoad/@PreSave/@PostSave methods (no-arg or a single ConfigContext) fire around the binder's read/write; the opt-in ConfigLifecycle interface offers the same four. Each gets a ConfigContext (section() + issues()).
  • Obsolete keys (in the file, not declared by the POJO): ObsoletePolicy.PRESERVE (default), REMOVE (strip), or COMMENT_OUT (keep + stamp a deprecation comment, on comment-capable codecs).

→ Deep dives: Typed Entity Binding · Annotations


Semantic rules

A type says what a value is; a rule says what it must mean. Declare the fact on the field, attach the consumer where the config is opened, and choose the policy per config — three axes that never entangle.

public class ServerConfig {

    @Comment("Port the service binds to.")
    @Min(1) @Max(65535)
    private int port = 25565;

    @Comment("Chance of a drop, in percent.")
    @Min(0) @Max(100)
    private double dropChance = 25.0;

    @Comment("Auth token. Fill it before the service will start.")
    @Explicit @NotBlank
    private String token = "";

    @OneOf(value = {"MONGO", "SQL", ""}, ignoreCase = true)
    private String dbType = "";

    @Unique
    private List<String> enabledWorlds = new ArrayList<>();
}
Config config = Config.open(path, codec)
        .withRuleEngine(StandardRules.engine())                      // jakarta + @Explicit/@OneOf/@Unique
        .withRulePolicy(RulePolicy.defaults().withCorrections(true));

ServerConfig cfg = config.loadAs(ServerConfig.class, codec);         // violations become LoadIssues
if (config.hasRuleFixes()) {
    config.save();                                                   // persist what the load repaired
}
  • They run inside the bind, on the channel that already exists: a violation becomes a LoadIssue of Kind.RULE, so a @PostLoad (or LoadIssueAware, or loadAsResult) written before rules existed sees them without one line of change.
  • The policy decides the cost. REPORT / LOG / THROW. With no policy declared, a value from the FILE follows the bind's Coercion (strict throws, lenient reports); a value from the entity's own DEFAULT throws out of the box — that is a code defect no config file can fix, and the message says so.
  • @ConfigRule is the whole SPI. Mark your own annotation with it, point it at a handler, and it fires with no setup line — the built-in engine is attached from the start.
  • The entity gets the last word. A @RuleReview method (or the RuleReviewer interface) runs after the engines and before the policy: it can accept a magic word the annotation could never list, override a severity, correct the value, or fail on logic of its own — including logic that reads another config.
  • The same declarations build a screen. RuleModel.of(type, selector) returns every rule of a type with the exact FILE path its value lands at, its @Comment and its default — no Config, no ObjectMapper, no file touched.
  • And they can document themselves. withRuleComments(true) folds each rule's own text into the comment at its path, composed into the @Comment already there.

Jakarta's constraints are read natively — the annotations only, no provider and no Bean Validation implementation anywhere. Two declared divergences: null passes everything except presence (@NotNull/@NotEmpty/@NotBlank), and the range constraints accept every numeric type, double included, compared in BigDecimal.

→ Deep dive: Semantic Rules


Self-describing types

A custom type can carry its own config codec, so it round-trips with no central registration — the shared mapper honors it in every context (a solo value, a POJO field, a list element).

Jackson-native (recommended). Because EveryConfig is Jackson-first, a type that declares @JsonValue + @JsonCreator already self-describes:

class Coord {
    final int x, y;
    @JsonValue String encode()             { return x + ":" + y; }   // -> the string "3:4"
    @JsonCreator static Coord of(String s) { /* parse "x:y" */ }
}

cfg.setValue("spawn", new Coord(3, 4));        // stored as "3:4"
Coord back = cfg.getValue("spawn", Coord.class);

An enum that declares a @JsonValue keeps its custom form too (a plain enum still serializes by name()). @JsonValue/@JsonCreator are the whole story for a self-describing scalar or object — and a type you cannot edit gets the same treatment through a Jackson mixin registered on the codec's mapper.

A codec-backed Config (Config.open / Config.inMemory) is required; a bare new Config() takes only native values. Note that getValue(path) without a type token still returns the raw string/map — a self-describing codec reaches the typed reads and binding, not the untyped dynamic read.

→ Deep dive: Self-Describing Types


@KeyIndex collections

A Collection<T> whose element carries a @KeyIndex field serializes as a section keyed by that field's value (it is omitted from the body; on read the section key is the sole authority). It is automaticsetValue and getList detect the @KeyIndex and use the keyed layout; there are no special methods.

class Account { @KeyIndex String name; int balance; /* ... */ }

cfg.setValue("accounts", Arrays.asList(
        new Account("alice", 100), new Account("bob", 50)));   // auto key-major
// accounts:
//   alice: { balance: 100 }
//   bob:   { balance: 50 }

List<Account> back = cfg.getList("accounts", Account.class);   // ids restored from the section keys

@KeyIndex may be String, a boxed/primitive numeric, boolean or UUID. A duplicate index value (or a type with two @KeyIndex fields) throws a BindException on write; getListResult(path, type) returns the list together with any read issues (e.g. a stray body id that disagreed with its section key).

→ Deep dive: @KeyIndex Collections

A compact element form

A type can be rich as a solo value/field but compact inside a list — no wall of {x,y,z} objects for a long List<Pos>. Declare the compact form with @EveryConfigCompactValue (encode) + @EveryConfigCompactCreator (decode); then — exactly like @KeyIndexsetValue/getList auto-detect it for a collection:

class Pos {                                                  // rich solo, compact in a list
    int x, y, z;
    @EveryConfigCompactValue   String toLine()             { return x + " " + y + " " + z; }
    @EveryConfigCompactCreator static Pos fromLine(String s) { /* parse "x y z" */ }
}

cfg.setValue("home", new Pos(1, 2, 3));                      // solo -> { x, y, z }        (rich)
cfg.setValue("spots", spots);                                // list -> ["4 5 6", "7 8 9"] (compact, auto)
List<Pos> back = cfg.getList("spots", Pos.class);            // tolerant: reads strings OR objects

Jackson ignores those annotations, so the solo/field form stays rich; only the dynamic setValue/getList path uses the compact one. For a type you cannot annotate, attach a CompactElementResolver to the codec (new YamlCodec(mapper, resolver)) mapping the type to a CompactElementCodec — a per-codec seam, no global registry. Nested List<T> fields inside a bound POJO stay rich for now.


Lifecycle, reload & watching

Config cfg = Config.open("app.yml");     // codec from the extension; absent -> empty; malformed -> .bak (never throws)
Config db  = Config.open(path, codec, Durability.FSYNC); // optional: force bytes to disk on each save (crash-safe)
cfg.save();                              // atomic write under a per-config lock
cfg.saveIfDirty();                       // no I/O when nothing changed
cfg.saveAsync();                         // on a shared daemon executor
cfg.reload();                            // re-read from disk
cfg.onReload(() -> ...).withAutoReload(Duration.ofSeconds(2));       // poll on a daemon thread
cfg.onReload(() -> ...).withAutoReload(Duration.ofSeconds(2), true); // also catch a same-size edit (hashes content)
cfg.close();                             // idempotent; stops the watcher

In-memory & cross-format saves. A Config does not need a file at all, and it can be persisted in a different format than it was opened with:

Config mem = Config.inMemory();      // full typed/POJO API, no file — save() throws (use a real codec to persist)

cfg.save(new JsonCodec());           // one-shot: dump the tree as JSON, leaving the live codec unchanged
cfg.changeCodec(new TomlCodec());    // switch the format used by every subsequent save

Config.inMemory() carries a Jackson mapper, so setValue(path, pojo), getValue(path, type) and the binding annotations all work in memory; it just has nothing to write to. (A bare new Config() has no codec at all and accepts only native values.)

In-memory save principle. Outside a watcher, a Config lives entirely in memory: save() dumps the in-memory tree and never reads the file first. A hand-edit made while the app runs is overwritten on the next save unless the caller reload()s to pick it up.

→ Deep dive: Lifecycle, Reload & Watching


Building & running the tests

Prerequisites

  • JDK 25 — the only JDK you need to launch the build. The wrapper is Gradle 9.5.1 (runs on JDK 25 directly). Production sources compile on the Java 25 toolchain via Jabel with --release 8, so the published bytecode and API floor stay at Java 8.
  • The -PtestJdk=N multi-runtime runs need that JDK installed and discoverable by Gradle's toolchain auto-detection (e.g. under ~/.jdks). Auto-download is off.
export JAVA_HOME=/path/to/jdk-25      # PowerShell: $env:JAVA_HOME = "C:\path\to\jdk-25"

./gradlew build                                        # compile + run every module's tests on Java 25
./gradlew :core:test :rules:test -PtestJdk=8 --rerun-tasks   # the Java 8 runtime floor (also: 11, 17, 21)
./gradlew test -Pstress                                # opt-in stress & benchmark -> build/stress-report/<codec>.md

One harness, every codec. CodecMatrixTest (a testFixtures source set of :core) carries the codec hooks, the capability flags and the residual handling; each cross-codec contract extends it and runs its whole body against all four formats — AbstractConfigTest for the config contract, AbstractRulesetTest for the rule vocabulary, AbstractStressTest for throughput. Residual files land under build/test-residuals/<group>/ for inspection. -PtestJdk does not invalidate the test task's up-to-date check, so --rerun-tasks is required; the -Pstress suite (skipped by default) measures throughput, the 100k-entity scale, concurrency and the lock-cost trade-off.

→ Deep dives: Building from Source · Running the Tests · Benchmarks


Project layout

EveryConfig/
├── core/                        # -> everyconfig-core
│   └── src/main/java/br/com/finalcraft/everyconfig/
│       ├── config/              # Config (dynamic API + lifecycle) + config.section (ConfigSection)
│       ├── core/                # the canonical model: core.tree (DPath), core.coerce (NodeCoercion),
│       │                        #   core.comment (CommentTree), KeyOrder
│       ├── codec/               # Codec SPI, CommentFidelity, registry, mapper profiles
│       │   └── jackson/         # JsonCodec, YamlCodec, TomlCodec, JsoncCodec
│       ├── io/                  # file I/O: atomic write, .bak, poll watcher, async executor
│       ├── binding/             # typed binding: EntityBinder + binding.schema, binding.merge, binding.introspect
│       ├── rule/                # the rule SEAM and not one rule: @ConfigRule, RuleEngine, RuleModel,
│       │                        #   RulePolicy, @RuleReview, RuleBindDriver
│       ├── selfdescribe/        # the compact list-element SPI
│       └── annotation/          # @Key, @Comment, @Section, @KeyIndex, @PostLoad (+ KeyTransformCase, CommentMode)
└── rules/                       # -> everyconfig-rules (optional)
    └── src/main/java/br/com/finalcraft/everyconfig/ruleset/
        ├── (root)               # @Explicit, @OneOf (+ OneOfSource), @Unique, StandardRules
        ├── jakarta/             # JakartaRules + the 22 constraint handlers, grouped by kind
        └── support/             # shared helpers both vocabularies use

→ Deep dive: Project Layout


Compatibility notes

  • Java 8 runtime floor. The library is compiled with --release 8. Jabel lifts syntax but not runtime APIs, so the source avoids Java 9+ APIs; the -PtestJdk=8 run is the guard. Validated green on Java 8, 11, 17, 21 and 25.
  • Dependencies. Jackson databind + dataformat-yaml/-toml are on the public api surface (the tree and codecs expose Jackson types); jsr310 and jdk8 (java.time / Optional) are runtime. everyconfig-rules adds exactly one: jakarta.validation-api (an interfaces-only jar — never a Bean Validation provider).
  • Serialization. Bound entities must be Jackson-serializable (a no-arg constructor plus accessors/fields, or appropriate Jackson annotations).
  • No EverNifeCore, no Bukkit/Spigot API — pure Java.

Made by Petrus Pradella

About

A Jackson-backed configuration library for the JVM. Write your config code once against a small, typed, path-based API - then read and write it as YAML, JSON, TOML or JSONC without changing a line.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages