Appearance
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 (
.moonsources plus local assets), edited in place. - Packed — the boundary form you ship and store: a single
.moonsor.moon.zipfile.
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 bundle | Library bundle | |
|---|---|---|
Top-level main.moon | required | absent |
| What it is | one design — a project, a component, a publishable thing | a collection of items (in arbitrarily nested folders) |
| Default action | evaluate main.moon | browse / copy from |
| Closure | one 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 kind | Example | Classification |
|---|---|---|
| Local-relative | ./textures/Color.jpg, ../shared/leg.moon | in the closure |
| Remote-absolute | https://assets.moonomat.com/textures/ambientcg/Grass/Grass001_Color.jpg | reference (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
.moonsis 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
.moonsunless 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 URL —
https?://…only; MUST be quoted (see below). Preferhttps://— anhttp://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.moonanda.moonare 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
!!binaryscalar (§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
\tand\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.
- Chomping preserves trailing newlines exactly: one trailing
- 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.zipform (§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 owndoc:andparams:describe the design). A loose working copy's entry is often named otherwise (e.g.lamp.moon); the packer renames the chosen entry tomain.moonwhen 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 nomain.moontreats 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.moon → export/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:
- If
Xis an absolute URL → candidate =X. - Else (relative) → candidate = normalize
dirname(K) + "/" + Xin K's namespace (path-relative for a path key, URL-relative for a URL key); reject..escaping the root for path keys. - 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
.moonfile — its path with.webpappended (main.moon→main.moon.webp;materials/Grass.moon→materials/Grass.moon.webp). Any.moonmay 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
!!binaryor 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