Lmml.Bundle (Lmml v0.1.0)

View Source

A loaded lmml entity, regardless of which of the two on-disk forms it came from:

  • .lmml -- a bare, self-contained Markdown-superset text file. There is nowhere for an external reference to point to, so every embed it mentions must be inline.
  • .lmmlz -- a zip archive whose canonical narrative entry is named by stripping the trailing z from the archive's own filename (foo.lmmlz contains foo.lmml), plus whatever other entries its @name.ext references point at.

open/1 detects which form a file is by sniffing its content (the zip local-file-header magic bytes), not by trusting its extension, and either way returns a Bundle exposing the exact same API: narrative/1, embeds/1, embed/2, entries/1.

Summary

Types

Which on-disk form this bundle was loaded from (or is destined for).

t()

A single issue found by validate/1.

Functions

Resolves a named embed's actual content: inline embeds return their own captured text directly (working for any bundle kind); external references are looked up among the zip's entries, which only exists for a :zip bundle.

Every embed mentioned in the narrative (see Lmml.Document's embeds field).

Names of the zip entries this bundle carries, excluding the narrative itself. Always [] for a :text bundle.

The bundle's own logical name (always ending in .lmml, regardless of bundle kind).

The bundle's raw narrative text.

Builds a bare-text bundle directly from narrative text, without touching disk. name is normalized to always end in .lmml (the logical narrative name is the same regardless of file form).

Builds a zip-backed bundle directly from narrative text plus a map of external entries (%{"image.png" => <<bytes>>}), without touching disk. Every entry name is path-safety validated the same way open/1 validates entries read from a real archive.

Opens a lmml entity from disk, auto-detecting whether path is a bare text narrative or a zip archive by sniffing its content.

Same as open/1, but raises on failure.

True for a bundle backed by a bare text narrative (no external files possible).

Cross-checks a bundle's narrative against its own entries and its own internal consistency. This is a distinct, opt-in step from open/1 or new_zip/3 (which only ever reject an entry name that is outright unsafe -- see validate_entry_names/1): validate/1 instead catches authoring mistakes that are still perfectly well-formed as far as parsing and path-safety go. Every issue found is returned at once, rather than stopping at the first

Validates that none of names could escape the bundle root when resolved: no absolute paths, no .. path segments. Used both when opening a zip archive (every entry it contains) and when resolving a single reference at read time.

Persists a bundle to disk in its current form (text stays text, zip stays zip) -- this does not convert between forms; see Lmml.Pack for that. For a :zip bundle, the internal narrative entry name is always freshly derived from path's own basename (not from whatever name the bundle was constructed/opened with), so whatever you write is always internally self-consistent with its own filename.

True for a bundle backed by a zip archive.

Types

kind()

@type kind() :: :text | :zip

Which on-disk form this bundle was loaded from (or is destined for).

t()

@type t() :: %Lmml.Bundle{
  document: Lmml.Document.t(),
  entries: %{optional(String.t()) => binary()},
  kind: kind(),
  name: String.t(),
  narrative: binary()
}

validation_issue()

@type validation_issue() ::
  {:missing_reference, String.t()}
  | {:orphaned_entry, String.t()}
  | {:malformed_embed_name, String.t()}
  | {:conflicting_embed, String.t()}

A single issue found by validate/1.

Functions

embed(bundle, name)

@spec embed(t(), String.t()) :: {:ok, binary()} | {:error, term()}

Resolves a named embed's actual content: inline embeds return their own captured text directly (working for any bundle kind); external references are looked up among the zip's entries, which only exists for a :zip bundle.

embeds(bundle)

@spec embeds(t()) :: [Lmml.Embed.t()]

Every embed mentioned in the narrative (see Lmml.Document's embeds field).

entries(bundle)

@spec entries(t()) :: [String.t()]

Names of the zip entries this bundle carries, excluding the narrative itself. Always [] for a :text bundle.

name(bundle)

@spec name(t()) :: String.t()

The bundle's own logical name (always ending in .lmml, regardless of bundle kind).

narrative(bundle)

@spec narrative(t()) :: binary()

The bundle's raw narrative text.

new_text(name, narrative)

@spec new_text(String.t(), binary()) :: {:ok, t()} | {:error, term()}

Builds a bare-text bundle directly from narrative text, without touching disk. name is normalized to always end in .lmml (the logical narrative name is the same regardless of file form).

new_zip(name, narrative, entries \\ %{})

@spec new_zip(String.t(), binary(), %{optional(String.t()) => binary()}) ::
  {:ok, t()} | {:error, term()}

Builds a zip-backed bundle directly from narrative text plus a map of external entries (%{"image.png" => <<bytes>>}), without touching disk. Every entry name is path-safety validated the same way open/1 validates entries read from a real archive.

open(path)

@spec open(Path.t()) :: {:ok, t()} | {:error, term()}

Opens a lmml entity from disk, auto-detecting whether path is a bare text narrative or a zip archive by sniffing its content.

open!(path)

@spec open!(Path.t()) :: t()

Same as open/1, but raises on failure.

text?(bundle)

@spec text?(t()) :: boolean()

True for a bundle backed by a bare text narrative (no external files possible).

validate(bundle)

@spec validate(t()) :: :ok | {:error, [validation_issue()]}

Cross-checks a bundle's narrative against its own entries and its own internal consistency. This is a distinct, opt-in step from open/1 or new_zip/3 (which only ever reject an entry name that is outright unsafe -- see validate_entry_names/1): validate/1 instead catches authoring mistakes that are still perfectly well-formed as far as parsing and path-safety go. Every issue found is returned at once, rather than stopping at the first:

  • {:missing_reference, name} -- an @name.ext reference has no matching zip entry. For a :text bundle this fires for every external reference, since a bare .lmml has no entries at all to resolve one against.
  • {:orphaned_entry, name} -- a zip entry that no embed in the narrative actually mentions.
  • {:malformed_embed_name, name} -- an embed name (inline or external) that could not safely become a zip entry name (see validate_entry_names/1). This applies to every embed, not only inline ones, since Lmml.Pack.pack/2 (planned) can turn any inline embed into a real zip entry later.
  • {:conflicting_embed, name} -- two or more embeds share a name but disagree on content (including disagreeing on inline-vs-external), which is an authoring mistake: a name is meant to identify one entity consistently throughout a narrative.

Returns :ok when none of the above apply.

validate_entry_names(names)

@spec validate_entry_names([String.t()]) ::
  :ok | {:error, {:unsafe_entry, String.t()}}

Validates that none of names could escape the bundle root when resolved: no absolute paths, no .. path segments. Used both when opening a zip archive (every entry it contains) and when resolving a single reference at read time.

Note: for archives that actually went through :zip.unzip/2 (i.e. every entry reaching this check via open/1), Erlang's own zip implementation has already sanitized traversal/absolute entry names down to a bare basename (logging a warning as it does so) by the time this runs -- so in practice this check rarely has anything to catch on that path. It remains meaningful as the only line of defense for entries that never go through :zip at all, i.e. those supplied directly to new_zip/3.

write!(bundle, path)

@spec write!(t(), Path.t()) :: :ok

Persists a bundle to disk in its current form (text stays text, zip stays zip) -- this does not convert between forms; see Lmml.Pack for that. For a :zip bundle, the internal narrative entry name is always freshly derived from path's own basename (not from whatever name the bundle was constructed/opened with), so whatever you write is always internally self-consistent with its own filename.

zip?(bundle)

@spec zip?(t()) :: boolean()

True for a bundle backed by a zip archive.