Skip to content
mdlake

Lake format

The exact layout and file formats of a lake repository (documents, folder order in `_folder.yaml`, media sidecars, the manifest) and of the `lake.lock` file in a site. For developers and anyone who reads or writes the lake without the CMS.

On this page

The lake is a Git repository of plain files. The CMS writes it when you publish, and sites read it. Because the format is just Markdown, JSON and the folders between them, you can also read a lake with any tool, import one that you wrote by hand, or check what the CMS has written. This page is the full specification of what is in the repository.

For the idea behind the lake, see What is mdlake.

What the repository holds

lake-acme/
  _manifest.json
  About/
    about.md
  Services/
    _folder.yaml
    process-automation.md
    custom-business-systems.md
  Cases/
    regional-distributor.md
  assets/
    01JBXQAP4V9W5X1Y3A8B2C7D6H.jpg
    01JBXQAP4V9W5X1Y3A8B2C7D6H.json
    01JBXQ0A9C3E5G7H1K4M6P8R2T.pdf
    01JBXQ0A9C3E5G7H1K4M6P8R2T.json
    01JBXQ0A9C3E5G7H1K4M6P8R2T.md
  _meta/
PathWhat it is
<folder>/<name>.mdA published document. The folders are the editors’ folders.
<folder>/_folder.yamlOptional. The folder’s own place among the items of its parent folder. See Order and folder files.
assets/<id>.<ext>A published media file (an image or a document such as a PDF).
assets/<id>.jsonThe sidecar of that file: its facts and the editors’ metadata.
assets/<id>.mdFor document files only: the extracted text, so that tools and AI can read it.
_manifest.jsonAn index of everything published. Rewritten on every publish.
_meta/Reserved for metadata. The loader skips it. The example lake has _meta/tags.yaml, and the builder’s check-lake skill reads _meta/requirements/ when it exists.

Only published content is in the lake. Drafts live in the CMS’s database, not in Git. Unpublishing removes the file.

Identity: ULIDs, never paths

Every document and every media file has an ID, a ULID: 26 characters from Crockford’s base32 (digits and capital letters without I, L, O and U), for example 01JBXQ4G8N3P9Q5R7T2V6W1X0B. IDs sort by creation time.

The ID is how things are found and linked. A path is only where the file happens to be: editors rename and move documents, and the ID stays. Sites bind to IDs for this reason, never to paths. See How sites work.

Documents

A document is a Markdown file with YAML frontmatter. The CMS writes the keys in this order, with order after updated and only when the document has a number:

Markdown
---
id: 01JBXQ4G8N3P9Q5R7T2V6W1X0B
title: Process automation
summary: We map how work really flows through your company and automate the repetitive parts.
tags: [service]
status: published
slug: process-automation
created: 2026-03-02T09:30:00Z
updated: 2026-08-20T10:00:00Z
order: 10
---

Most companies carry a handful of processes that eat hours every week.

## What you get

- A process map that shows where time and errors accumulate

Frontmatter

KeyRequiredRule
idyesA ULID.
titleyesNot empty.
summaryno (defaults to empty)Plain text, one paragraph, at most 300 characters.
tagsnoUp to 20 tags, each at most 50 characters.
statusyesdraft or published. In the lake it is published.
slugnoLowercase words separated by single hyphens (^[a-z0-9]+(?:-[a-z0-9]+)*$), up to 100 characters. It becomes the last part of the page address on a site.
templatenoA free-text template name.
created, updatedyesUTC timestamps in the form 2026-03-02T09:30:00Z.
ordernoA number. Lower comes first among the items of the document’s own folder. The CMS owns it: editors set it by dragging in the tree, and it is written as a whole number. See Order and folder files. A value that is not a number is reported as an issue, never ignored.

Any other key is kept as it is. For example, image can hold an asset ID, and a site’s template can read it. The CMS refuses an extra key with the name of a contract key (id, title, summary, tags, status, slug, template, created, updated, order).

The frontmatter is YAML only. A file that tries to run code (---js) is refused by the loader. Anchors, aliases and custom tags in the YAML are not interpreted.

Body

The body is CommonMark Markdown with the subset the editor supports. Two kinds of links connect the lake:

LinkMeaning
lake://<ID>Another document. Add #heading-anchor to point at a section: lake://01JBXQ6J0Q5R1S7T9W4X8Y3Z2D#results.
asset://<ID>A media file, in an image (![Alt text](asset://<ID>)) or a link.

The links stay in the Markdown. A site turns them into addresses when it builds, and the editor turns them into previews for display.

Folders and file names

PartRule
Folder nameLetters, digits, spaces, hyphens and underscores; at most 64 characters; it cannot start with _ or ..
DepthAt most 8 levels; the whole path at most 400 characters.
Reserved top-level namesassets and _meta, in any capitalisation.
File nameLetters, digits, hyphens and underscores, starting with a letter or digit; at most 100 characters, plus .md.

Order and folder files

Every item in a folder, a document or a subfolder, can have its own order number. The number only means something among the items of the folder it is in. A document keeps its number in its frontmatter. A folder keeps its number in a small file inside it:

YAML
order: 20

That file is <folder>/_folder.yaml, for example Services/_folder.yaml. It positions the folder Services among the items of its parent, which here is the top level.

RuleDetail
WhereInside the folder it describes. It is YAML and not Markdown, so nothing mistakes it for a document.
orderA number. Lower comes first. The CMS writes whole numbers, 10 apart.
Other keysKept. When the CMS changes the number, the other keys stay in the file.
When it existsOnly while the folder has a number and published content. The CMS adds it when the first document in a numbered folder is published and removes it when the last one goes away. A folder that holds only _folder.yaml is never written.
At the topA _folder.yaml at the root of the lake does nothing, because the root has no parent. The loader reports it as an issue.
Not readA _folder.yaml in _meta/ or in the root assets/ folder.

The sorting rule

One rule sorts the items of one folder, documents and subfolders mixed:

  1. Items with an order come first, lowest number first.
  2. Then the items without one, by title (a document) or name (a folder), in one sequence.
  3. Items with the same number, or the same title or name, are sorted by title or name, ignoring case and reading numbers as numbers (Part 2 before Part 10).

A folder never takes a number from the documents inside it, and a document never takes one from outside its folder. A folder without its own number sorts after the numbered items, even when every page inside it has one.

Who sets the numbers

In a workspace the CMS owns order. Editors reorder in the tree (Reorder documents and folders), and the CMS works out the numbers and writes them to the lake. A document in the CMS holds its number next to its folder, not in its draft, so reordering never makes a document “changed”. When you write a lake by hand, you write the numbers yourself. Leaving gaps (10, 20, 30) makes room to insert.

The read API’s /manifest carries the folder numbers as folders: [{ path, order }], for numbered folders that have published content (Workspace tokens). The _manifest.json file in the lake does not change: it has no folder numbers.

Media sidecars

Each published media file has a JSON file next to it with the same ID. The sidecar is what a site reads. The CMS writes it; sites and tools only read it.

Images

JSON
{
  "id": "01JBXQAP4V9W5X1Y3A8B2C7D6H",
  "kind": "image",
  "file": "assets/01JBXQAP4V9W5X1Y3A8B2C7D6H.jpg",
  "url": "https://acme.blob.core.windows.net/public-assets/01JBXQAP4V9W5X1Y3A8B2C7D6H.jpg",
  "mime": "image/jpeg",
  "width": 1600,
  "height": 1067,
  "alt": "Warm abstract shapes in terracotta and sand",
  "caption": "",
  "focal": { "x": 0.5, "y": 0.35 },
  "crop": true,
  "rights": "Own material",
  "tags": []
}

Documents

JSON
{
  "id": "01JBXQ0A9C3E5G7H1K4M6P8R2T",
  "kind": "document",
  "file": "assets/01JBXQ0A9C3E5G7H1K4M6P8R2T.pdf",
  "url": "https://acme.blob.core.windows.net/public-assets/01JBXQ0A9C3E5G7H1K4M6P8R2T.pdf",
  "mime": "application/pdf",
  "title": "Annual report 2025",
  "bytes": 482113,
  "pages": 24,
  "rights": "",
  "tags": ["report"]
}

Fields

FieldKindsMeaning
idbothThe ULID.
kindbothimage or document.
filebothThe repository path of the file, assets/<id>.<extension>.
urlboth, optionalThe public address the file is served from (the CMS’s public container, never a signed or private link). The CMS writes it. It is missing in sidecars published before the CMS wrote it, in unpublished previews and in the example lake. Sites download assets from here, or from PUBLIC_ASSET_BASE_URL when that is set.
mimebothThe media type.
width, heightimagePixels.
altimageThe alternative text. Empty when nobody wrote one; a site should treat that as missing content.
captionimageA caption.
focalimageThe focal point as fractions of the image: x and y from 0 to 1, where (0, 0) is the top left. Default (0.5, 0.5). Sites keep it in view when they crop.
cropimagetrue by default. false for images with text or a logo: a site must show them whole, never cropped.
titledocumentThe title.
bytesdocumentThe size.
pagesdocumentThe page count, when known.
rightsbothWho owns it or on what terms it may be used. Free text.
tagsbothThe same tag rules as documents.

Other keys are kept. The loader validates each sidecar against these rules and reports a broken one as an issue.

The text of a document file

For a document file the CMS also writes assets/<id>.md:

Markdown
---
id: "01JBXQ0A9C3E5G7H1K4M6P8R2T"
title: "Annual report 2025"
kind: document
file: "assets/01JBXQ0A9C3E5G7H1K4M6P8R2T.pdf"
mime: "application/pdf"
rights: ""
tags: ["report"]
---

The extracted text of the file, as Markdown.

It has no status, created or updated, so it is not a document in the sense of the previous section. The loader never reads the assets/ folder at the root of the lake as documents (since builder 0.19.1), so a sidecar like this one is not reported as an issue and does not fail a build. The CMS reserves the folder name assets (in any case) for documents, so no document lives there. A folder with that name deeper in the tree, such as Docs/assets/, is read as usual. With an older kit, a published PDF made every build fail on this file.

The manifest

_manifest.json lists every published document and the media that must always be available. It is rewritten at every publish. It is deterministic: entries are sorted by path and there is no timestamp of its own, so a publish that changes nothing changes nothing in the file.

JSON
{
  "version": 1,
  "documents": [
    {
      "id": "01JBXQ4G8N3P9Q5R7T2V6W1X0B",
      "path": "Services/process-automation.md",
      "title": "Process automation",
      "summary": "We map how work really flows through your company.",
      "tags": ["service"],
      "slug": "process-automation",
      "links": ["01JBXQ6J0Q5R1S7T9W4X8Y3Z2D"],
      "assets": ["01JBXQAP4V9W5X1Y3A8B2C7D6H"],
      "updated": "2026-08-20T10:00:00Z"
    }
  ],
  "assets": [
    { "id": "01JBXQCR6X1Y7Z3A5C0D4E9F8K", "path": "assets/01JBXQCR6X1Y7Z3A5C0D4E9F8K.json", "kind": "image", "title": "Logo" }
  ]
}
FieldMeaning
versionAlways 1.
documents[].id, path, title, summary, tags, updatedCopied from the document.
documents[].slugLeft out when the document has none.
documents[].linksThe IDs of the lake:// links in the body, in order of first appearance.
documents[].assetsThe IDs of the media the document needs: asset:// links in the body, asset:// links in extra frontmatter strings, and a top-level image key that holds an asset ID.
assets[]Media that is always included (for example brand assets): id, the path of its sidecar, kind and title. The key is left out when there are none.

A tool that needs an overview of the lake reads the manifest first and opens documents only when it needs the body. The builder’s check-lake skill does this.

How the builder reads the lake

The loader (@mdlake/lake-loader) reads a lake folder as follows:

  • It reads every .md file outside _meta, .git and node_modules, and every _folder.yaml outside them.
  • It validates the frontmatter with Zod. A file that is invalid, cannot be parsed, or repeats an ID is reported as an issue, with its path and the reason. Issues are never skipped silently, and npm run check fails on them.
  • It validates each _folder.yaml the same way. A file that is not valid YAML or not a mapping, an order that is not a finite number, and a file at the root of the lake are issues. The numbered folders are available to sites as lake.folders, with the path from the lake root.
  • It keeps drafts out, except in a preview.
  • It reads every JSON sidecar outside _meta and validates it.

Sites bind to the lake in lake.config.ts by ID for single documents and by query for collections:

Query keyMeaning
folderA folder relative to the lake root, including its subfolders.
tagsThe document must have every listed tag.
sorttitle (default), manual, order, updated-desc or updated-asc. order sorts by the documents’ order, then by title. Folders play no part in a collection.
orderA list of document IDs in display order, for sort: manual. Documents not listed follow, by title.

updated-desc and updated-asc change the order whenever someone edits a document. Use them for news and blogs only.

lake.lock

A site records which commit of the lake it is built from in lake.lock, at the site’s root. It is JSON:

JSON
{
  "lake": {
    "workspace": "acme",
    "remote": "https://github.com/acme-org/lake-acme.git",
    "commit": "9b1c0e2d7f4a6c3b8e5d1a0f2c4b6d8e9a7c5b3d",
    "lockedAt": "2026-10-20T09:14:00Z"
  },
  "mode": "snapshot"
}
FieldMeaning
lake.workspaceThe workspace slug.
lake.remoteThe Git address of the lake. Optional. Never contains credentials: an address with a user or token is refused. Without it, the site needs LAKE_PATH for every build.
lake.commitThe commit the site is built from. It must be the full 40-character hash in lower-case hexadecimal, because the kit fetches it by hash. An abbreviation makes lake.lock invalid. SHA-256 repositories are not supported; the CMS creates SHA-1 repositories.
lake.lockedAtWhen it was locked.
modesnapshot: build only from the locked commit, and move it with the refresh flow. live: follow the latest commit, and do not check.

The refresh flow moves commit and lockedAt and nothing else. A tool writes this file, not you. See Refresh content and The lake in CI, and caches.

Writing a lake by hand

You can build a lake without the CMS, for tests or for a first import:

  1. Create the folders and .md files with the frontmatter above. Generate IDs with any ULID tool. The Markdown folder import in the CMS can bring such files in and gives them IDs where they have none. No frontmatter is required: the title comes from the frontmatter, else from the first level-1 heading, else from the file name. An id in the frontmatter is kept when it is a valid ULID that is free. See Importing content. Fields the CMS manages, such as status, created and updated, are not taken from the file; you set them when you write a lake without the CMS, as in the next step.
  2. Set status: published and both timestamps. To order the documents, add order numbers, and put order: 20 in a _folder.yaml inside each folder you want to place.
  3. For media, put the file and its sidecar in assets/, with the same ID. Use kind, file, mime and, for images, width and height.
  4. Run the site’s npm run check:bindings. Every problem in the lake is listed as an issue with its path.

A lake that you write by hand has no _manifest.json until the CMS publishes. The loader does not need it. The builder’s skills use it when it exists.

What’s next