Skip to content

Moon Bundles Specification

A Moon bundle is the single-file, self-contained, distributable form of Moon content — a design (a project rooted at an entry main.moon) or a library (a collection of items) — together with all the local files it imports.

This document specifies the bundle model and its two container forms, the text bundle .moons and the zip form .moon.zip, precisely enough to implement an independent, conformant reader or packer. Format version: v1.

1. The model

1.1 Open and packed

A bundle exists in two states:

  • Open — the live working copy: loose files (.moon sources plus local assets), edited in place.
  • Packed — the boundary form you ship and store: a single .moons or .moon.zip file.

Packing and unpacking are lossless inverses (modulo the newline normalization of §2.5): the packed bundle carries exactly the open bundle's files.

A packed bundle is an interchange form, not an output: consuming it still requires Moon evaluation. Evaluated results (.glb / .svg / .png / .json) are separate artifacts (§1.3, §2.8) — to ship a rendered result, publish that file itself, not a bundle.

1.2 Design bundles and library bundles

A bundle's kind is determined by exactly one signal — the presence of a top-level key main.moon:

Design bundleLibrary bundle
Top-level main.moonrequiredabsent
What it isone design — a project, a component, a publishable thinga collection of items (in arbitrarily nested folders)
Default actionevaluate main.moonbrowse / copy from
Closureone closure, rooted at main.moon (§1.4)none — each item is its own root

Both kinds use the same container — "design vs library" is a property of the contents, not a different file type — and a single reader reads both. Library-specific rules are in §5.

1.3 Contents: source plus thumbnails, never outputs

A bundle contains the design's source — every file the user keeps, whether or not it is (yet) referenced by an import: — plus small preview thumbnails (§4). It never contains evaluated asset outputs: those are deterministic, regenerable results of evaluation, they live under the reserved export/ directory (§2.8), and a packer MUST exclude them from every packed bundle.

Absolute-URL imports (e.g. CDN textures) are references — never bundled unless explicitly vendored (§2.9).

1.4 The dependency closure; sealed and open

The closure of an entry .moon is the set of files reachable from it by import: edges, with every edge classified:

Edge kindExampleClassification
Local-relative./textures/Color.jpg, ../shared/leg.moonin the closure
Remote-absolutehttps://assets.moonomat.com/textures/ambientcg/Grass/Grass001_Color.jpgreference (unless vendored, §2.9)

Closure computation is static — non-evaluating and non-fetching. It therefore depends on the static-import invariant: import targets are literal strings, never computed from params or expressions.

A bundle is sealed when every transitive import resolves within it — guaranteed to work offline. Otherwise it is open: some references are fetched at runtime.

The closure is a derived analysis, applied only where minimality is the explicit goal (a minimal share artifact; copying one item out of a library, §5.2). A packed bundle normally carries the whole source (§1.3), so an unreferenced file is never silently dropped.

2. The text bundle (.moons)

2.1 Identity and form selection

  • Extensions: .moons (text bundle, this section) · .moon.zip (zip form, §3).
  • Media types: application/moons+yaml · application/zip.
  • Signature: the first line of a .moons is the comment # moons v1. A packer MUST emit it; a reader SHOULD tolerate its absence. It carries the format version without a structural key, is ignored by YAML parsers, and lets tools sniff the format.
  • Form selection (by weight, not presence, of binary): a bundle packs as text .moons unless its total binary payload reaches ~25 MB, above which the ~33% base64 overhead is not worth it and it packs as .moon.zip. A small binary (a thumbnail, an icon) therefore stays in the text form; the zip is the exception, not the default.
  • Readers are liberal: a reader accepts any .zip, not only .moon.zip — if the archive contains a .moon, it is treated as a bundle (entry = main.moon, else the first root-level .moon), so a hand-zipped bundle folder opens too.

2.2 Structure

A .moons is one YAML 1.2 document whose root is a mapping. Each entry is one file:

<key: path-or-URL>: <value: content>

There are no other top-level constructs. Folders are implicit in keys; empty folders are not representable.

2.3 Keys

A key is a non-empty string, either:

  • Relative path — POSIX / separators; no leading /; no . or .. segments; no empty segments; no backslashes. Stored in Unicode NFC. Case-sensitive.
  • Absolute URLhttps?://… only; MUST be quoted (see below). Prefer https:// — an http:// reference is mixed-content-blocked when the consumer is served over HTTPS.

Keys MUST be unique. A URL key means the file is vendored: it satisfies an import: of that exact URL (§2.9). The key is the reference string — there is no separate vendor folder or URL map.

Quoting (mandated for byte-identical output, §2.11). A key is emitted as a double-quoted (JSON-style) scalar — never plain or single-quoted — whenever a plain emission would be ambiguous or invalid: any absolute URL; any key the YAML core schema would resolve to a non-string (true/false, null/~, an integer or float such as 123, 0x10, 1.0, .inf); any key containing a YAML indicator character (:, #, |, >, &, *, !, %, @, backtick, [, ], {, }, ,) or beginning with - or ?; and any key with leading or trailing whitespace. Otherwise the key is emitted plain. Two conformant packers MUST make the same choice here.

Cross-platform note: A.moon and a.moon are both legal but won't round-trip to a case-insensitive filesystem; a packer SHOULD warn.

2.4 Values

Each value is one of:

  • Text file → a literal block scalar string (§2.5).
  • Binary file → a !!binary scalar (§2.6).
  • Empty file"". An all-newline file (content is only line breaks) → a double-quoted string ("\n", "\n\n", …), because a block scalar's chomping indicators cannot represent a body that is purely line breaks.

An untagged scalar — block, plain, or quoted — is text; a !!binary-tagged scalar is binary. No other forms.

2.5 Text entries

  • Encoding & text-qualification (order matters). Content is UTF-8. First normalize CRLF → LF; then a file qualifies as text iff what remains is valid UTF-8 with no control characters except \t and \n — otherwise it MUST be stored as binary (§2.6). Consequence: a CRLF file round-trips as LF text, but a file with an interior lone \r, a form-feed, or another C0 control becomes a base64 blob. (CRLF does not survive — a file needing exact CRLF must be binary; this is the only lossy normalization.)
  • Style: literal block scalar |, content indented 4 spaces (matching Moon's own indentation convention).
    • Chomping preserves trailing newlines exactly: one trailing \n|; none → |-; two or more → |+.
    • Emit an explicit indentation indicator (|4, |-4, …) whenever the first content line begins with whitespace or is empty.
  • Verbatim guarantee: because every content line is prefixed with the block indent, no content line sits at column 0 — content can never impersonate the next key. Comments and inner formatting are preserved byte-for-byte (modulo the LF rule).

2.6 Binary entries

yaml
textures/grain.png: !!binary |
    iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQ
    DwAEhQGAhKmMIQAAAABJRU5ErkJggg==
  • Standard base64 (RFC 4648, padded), wrapped at 64 columns. Whitespace/newlines are ignored on decode, so chomping is irrelevant for binary — use |.
  • !!binary (tag:yaml.org,2002:binary) comes from the YAML 1.1 type library, not the 1.2 core schema — a strict 1.2 reader decodes it only if it opts the tag in; a conformant Moon-bundle reader MUST.
  • The only binary encoding in v1 is base64 via !!binary.
  • Guidance: inline binary is for small payloads (a thumbnail, an icon). Large textures/meshes should be external URL references or live in the .moon.zip form (§3).

2.7 Bundle kind and entry

A bundle's kind is determined by one rule: a top-level key exactly main.moon (§1.2).

  • Design bundle — has main.moon. It is the entry: evaluation starts there, and the closure is rooted there (convention over manifest — there is no manifest file; the entry's own doc: and params: describe the design). A loose working copy's entry is often named otherwise (e.g. lamp.moon); the packer renames the chosen entry to main.moon when packing a design bundle. This is safe only because the entry is the root and is never itself an import target — a precondition the packer MUST verify: if any other file imports the entry by its original name, the packer MUST reject or rewrite that reference rather than silently break it on rename.
  • Library bundle — has no top-level main.moon (§5). Nothing auto-evaluates; a reader that finds no main.moon treats the bundle as a library.

main.moon is thus a reserved key: a design bundle MUST contain one, and a library bundle MUST NOT.

2.8 Reserved path: export/

Evaluated outputs are written to a reserved top-level export/ directory, mirroring the source layout: export/<dir>/<base>.<ext> (e.g. parts/shade.moonexport/parts/shade.glb).

export/ is a regenerable local cache a tool MAY keep for fast preview and downloads. A conformant packer MUST exclude export/ from every packed bundle — it is never part of the source (§1.3). Tool-private directories (such as .vscode/) are likewise excluded by the producing tool; the reserved, cross-tool name is export/.

2.9 Import resolution and vendoring

Evaluation starts at main.moon. For an import: X inside an entry with key K:

  1. If X is an absolute URL → candidate = X.
  2. Else (relative) → candidate = normalize dirname(K) + "/" + X in K's namespace (path-relative for a path key, URL-relative for a URL key); reject .. escaping the root for path keys.
  3. If candidate ∈ bundle → use it (decoded per §2.4). Else → fall back to the host resolver (disk/network), or error in sealed mode.

A bundle is sealed when every transitive import resolves within it and open otherwise (§1.4).

Vendoring is opt-in. Storing a remote file under its URL key (so the import resolves inside the bundle) is an explicit, per-edge choice — never automatic. Large remote assets (textures, meshes) stay references by default; only small files are ever vendored.

2.10 Ordering (mandated)

Entries are ordered by this tuple, compared lexicographically:

( 0 if key == "main.moon" else 1,            # entry first (design bundles)
  0 if Moon source else (1 if text else 2),  # source, then other text, then binary
  0 if relative path else 1,                 # vendored URLs after local files
  key in Unicode code-point order )

Moon source = keys ending .moon. The middle term keeps the readable design contiguous at the top even when a large non-source text file is present. This yields up to seven visual groups: main.moon · source/local · source/URL · other-text/local · other-text/URL · binary/local · binary/URL. (In a library bundle there is no main.moon, so the first term is 1 for every key.) The order is total → byte-identical re-exports and minimal, insert-in-place diffs.

2.11 Determinism

A given file set packs to a byte-identical .moons: fixed signature line (§2.1), mandated ordering (§2.10), mandated key quoting (§2.3), fixed 4-space indent, computed chomping/indentation indicators (§2.5), 64-column base64 (§2.6), LF text, NFC keys, and no timestamps or metadata anywhere. Same input → same bytes → same hash.

3. The zip form (.moon.zip)

A .moon.zip is a standard zip (application/zip) holding the same contents as a .moons — file paths as entry names, file bytes as entry data — used for binary-heavy or very large bundles (the ~25 MB threshold, §2.1). It is not a distinct format: any zip tool reads or produces one, and a tool can always unpack it and re-emit the text .moons.

All content rules of §2 apply unchanged — kind and entry (§2.7), export/ exclusion (§2.8), import resolution against the archive (§2.9). Text entries are stored as their raw UTF-8/LF bytes; binary entries as raw bytes (no base64).

A conformant packer additionally emits a canonical zip: entries sorted by the §2.10 ordering, zeroed timestamps, UTF-8 entry names, and STORE-vs-DEFLATE chosen per entry by a compressibility probe.

Caveat — determinism is engine-local. Unlike .moons, a .moon.zip is not portably byte-identical: DEFLATE output is encoder-defined, so different engines may emit different (valid) bytes for the same input; only STOREd entries are cross-engine stable. Tools MUST therefore compare bundle identity by hashing the source content file-by-file, never the packed archive bytes.

4. Preview thumbnails

  • Naming. A thumbnail is a WebP sidecar of a .moon file — its path with .webp appended (main.moonmain.moon.webp; materials/Grass.moonmaterials/Grass.moon.webp). Any .moon may have one. A design bundle carries exactly its entry's (main.moon.webp) as the bundle's card image; a library bundle carries each item's (§5.1).
  • One per listed thing. One image per card — a design's entry, or a library item — never a per-internal-file set.
  • Bounded. Small fixed dimension (target ≤ 256 px on the long edge), a few KB — within the small-payload guidance of §2.6.
  • Generated from the evaluated output, by type: 3D (.glb) → one optimal-view render, downscaled · 2D/image (.svg / .png) → the output image itself, downscaled · data (.json) → none.
  • Excluded from content hashes. A thumbnail is a non-deterministic render (camera, anti-aliasing, GPU). Any content hash used to detect change or divergence between copies of a bundle MUST exclude thumbnails, so a re-render never reads as a source change. In the format itself a thumbnail is an ordinary entry (text-bundle !!binary or raw zip entry) with no other special treatment.

5. Library bundles

A library is one library bundle — a bundle with no main.moon (§1.2, §2.7).

5.1 One bundle is the whole library

A library bundle is self-describing: its keys enumerate the items (e.g. materials/Grass.moon), its values are their source — which is also the search corpus (names, doc:, keywords, params:). There is no index file and no manifest. Each item's thumbnail rides inside the bundle as its <item>.moon.webp sidecar (§4) — raw entries in a .moon.zip, inline !!binary in a small .moons; the usual weight probe (§2.1) picks the container. A library too large for a one-shot fetch is sharded into per-category library bundles.

5.2 Consuming: browse and closure-copy

A library is not imported whole; an item is copied into a design, and copy is closure-aware (§1.4, with the item as the entry and the library as the file map): it copies the item's local closure — every file reachable by relative import: edges — into a chosen destination directory, preserving the item's internal relative structure so its relative imports keep resolving; absolute-URL imports stay references, verbatim. The result is self-contained: the design renders without the library.

5.3 Non-goals

  • No versioning / pinning (@1.2.0), no dependency resolution, no registry. Reuse is copy — a snapshot, not a live dependency.
  • No bundle-addressing imports (import: lib.moons#item). import: <url> stays the plain language feature, used where remote is required (heavy assets).
  • No enforced shape. "Tiny, reference-only" is a goal for published libraries, not a property of the type; a reader does not police contents, and nothing auto-vendors (§2.9).

6. Worked examples

A design bundle (rooted at main.moon):

yaml
# moons v1
main.moon: |
    moon: "1.0"
    doc: |
        A lamp shade: an SVG profile revolved into a solid, in a linen material
    render:
        op: ApplyMaterial
        with:
            material:
                use:
                    import: ./materials/linen.moon
        input:
            use:
                import: ./parts/shade.moon
materials/linen.moon: |
    moon: "1.0"
    doc: |
        Warm matte linen
    render:
        op: Material
        with:
            color: [0.87, 0.79, 0.65, 1]
            roughness: 0.9
parts/shade.moon: |
    moon: "1.0"
    doc: |
        The shade solid: the SVG profile revolved around the Y axis
    render:
        op: Revolve
        input:
            import: ./shade-profile.svg
parts/shade-profile.svg: |
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 2">
      <path d="M0,0 L1,0 L1,2 Z"/>
    </svg>
textures/grain.png: !!binary |
    iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQ
    DwAEhQGAhKmMIQAAAABJRU5ErkJggg==

Ordering check (§2.10): entry main.moon; then source/local (materials/linen.moon, parts/shade.moon); then other-text/local (parts/shade-profile.svg); then binary/local (textures/grain.png). Every import resolves within the bundle, so it is sealed (§1.4); textures/grain.png is not (yet) referenced by any import and is carried anyway (§1.3).

A library bundle (no main.moon — a collection of items, each referencing remote textures it never bundles; the remote references make it open, §1.4):

yaml
# moons v1
materials/Grass.moon: |
    moon: "1.0"
    doc: |
        Grass material. Keywords: lawn, field, turf, dry, wild
    render:
        op: Material
        with:
            color:
                import: https://assets.moonomat.com/textures/ambientcg/Grass/Grass001_Color.jpg
materials/Wood.moon: |
    moon: "1.0"
    doc: |
        Wood material. Keywords: plank, oak, floor, bark
    render:
        op: Material
        with:
            color:
                import: https://assets.moonomat.com/textures/ambientcg/Wood/Wood001_Color.jpg