Agents playbook

    This page is the operational playbook for coding agents that need to create or update Alinea content JSON directly. It focuses on deterministic rules that match Alinea core behavior and this repository's content shapes.

    Scope and source priority

    When documenting or generating Alinea content, use source of truth in this order:

    • The Alinea full-llms.txt file holds all documentation (including this playbook section). Use this as primary source of truth.

    • In case of ambiguity or missing documentation, consult the alinea core source code.

      • Projects using alinea will typically have alinea as a bundled node_modules dependency, which means the (compiled) source code can be accessed directly.

      • In case the source code can not be retrieved, or the compiled code is unclear, consult the source code on https://github.com/alineacms/alinea

    • Consult schema implementations and concrete JSON fixtures/content in the target project (for example content/main/**, content/demo/**) to find helpful examples and try to following the same structure when suggesting changes.

    • The source code of the alinea documentation website is also publicly available on https://github.com/alineacms/alineacms.com. The documentation website is itself created with Alinea + Next.js and can serve as a useful example.

    Project structure conventions

    In new Next.js projects, agents should follow the tutorial file structure as closely as possible to keep the codebase readable and maintainable.

    In existing codebases, first scan the current structure and then align new files and changes to the established coding guidelines and architectural principles.

    Entry metadata rules

    Top-level entries are JSON records with required meta fields. In Alinea core this is defined by EntryMeta in src/core/EntryRecord.ts.

    {
      "_id": "<createId()>",
      "_type": "<schema type name>",
      "_index": "<fractional index>",
      "_root": "pages",
      "_seeded": "/index.json",
      "title": "..."
    }
    • _id: unique entry id from createId() (alinea/core/Id).

    • _type: exact schema type key.

    • _index: fractional ordering key. Generate with generateKeyBetween from alinea/core/util/FractionalIndexing.

    • _root: required for root-level entries (entries without a parent). Value is the workspace root key, for example pages or media.

    • _seeded: only for entries that correspond to seeded config pages (Config.page(...)). Keep path stable.

    ID and index generation

    # New id
    node --input-type=module -e "import {createId} from 'alinea/core/Id'; console.log(createId())"
    
    # Index between two siblings
    node --input-type=module -e "import {generateKeyBetween} from 'alinea/core/util/FractionalIndexing'; console.log(generateKeyBetween('a0', 'a1'))"
    
    # Append after last sibling
    node --input-type=module -e "import {generateKeyBetween} from 'alinea/core/util/FractionalIndexing'; console.log(generateKeyBetween('a0', null))"

    Alinea sorts sibling entries by _index ascending. Never hand-pick _index by eye when inserting between entries.

    Links appear in two places: rich text marks and link fields (Field.link / Field.link.multiple).

    [
      {
        "_type": "paragraph",
        "content": [
          {
            "_type": "text",
            "text": "Internal doc",
            "marks": [
              {
                "_type": "link",
                "_id": "<createId()>",
                "_link": "entry",
                "_entry": "<target entry id>"
              }
            ]
          },
          {
            "_type": "text",
            "text": " and external site",
            "marks": [
              {
                "_type": "link",
                "_id": "<createId()>",
                "_link": "url",
                "href": "https://example.com",
                "target": "_blank",
                "title": ""
              }
            ]
          }
        ]
      }
    ]

    Rich text link mark shape is defined by LinkMark in src/core/TextDoc.ts: _type: link, _id, _link (entry | file | url), and optional _entry.

    // Field.link('Link') -> single entry link
    {
      "_id": "<createId()>",
      "_type": "entry",
      "_entry": "<target entry id>",
      "label": "Optional extra field"
    }
    
    // Field.link('Link') -> single external url
    {
      "_id": "<createId()>",
      "_type": "url",
      "_url": "https://example.com",
      "_title": "Example",
      "_target": "_blank",
      "label": "Optional extra field"
    }
    
    // Field.link.multiple('Links') row
    {
      "_id": "<createId()>",
      "_index": "<fractional index>",
      "_type": "entry",
      "_entry": "<target entry id>",
      "label": "Optional extra field"
    }

    For Field.link.multiple, each row is also a list row, so _index is required.

    Lists and union/list row metadata

    List rows are defined by ListRow in src/core/shape/ListShape.ts. Every row must include _id, _type, _index.

    {
      "items": [
        {
          "_id": "<createId()>",
          "_index": "a0",
          "_type": "Item",
          "title": "First"
        },
        {
          "_id": "<createId()>",
          "_index": "a1",
          "_type": "Item",
          "title": "Second"
        }
      ]
    }

    Union values (from UnionShape) require _id and _type. If a union is inside a list, it still needs list row _index as well.

    Rich text JSON format

    Alinea rich text is a TextDoc array (src/core/TextDoc.ts). Common internal nodes used by this project include heading, paragraph, text, bulletList, orderedList, listItem, hardBreak, and block nodes like CodeBlock/ImageBlock with _id.

    [
      {
        "_type": "heading",
        "level": 2,
        "content": [{"_type": "text", "text": "Heading"}]
      },
      {
        "_type": "paragraph",
        "textAlign": "left",
        "content": [
          {"_type": "text", "text": "Normal text "},
          {
            "_type": "text",
            "text": "bold",
            "marks": [{"_type": "bold"}]
          },
          {"_type": "text", "text": " "},
          {
            "_type": "text",
            "text": "italic",
            "marks": [{"_type": "italic"}]
          },
          {"_type": "hardBreak"},
          {
            "_type": "text",
            "text": "anchor",
            "marks": [
              {
                "_type": "link",
                "_id": "<createId()>",
                "_link": "url",
                "href": "https://example.com",
                "target": "_blank",
                "title": ""
              }
            ]
          }
        ]
      },
      {
        "_type": "bulletList",
        "content": [
          {
            "_type": "listItem",
            "content": [
              {
                "_type": "paragraph",
                "content": [{"_type": "text", "text": "Bullet item"}]
              }
            ]
          }
        ]
      },
      {
        "_type": "orderedList",
        "start": 1,
        "content": [
          {
            "_type": "listItem",
            "content": [
              {
                "_type": "paragraph",
                "content": [{"_type": "text", "text": "Ordered item"}]
              }
            ]
          }
        ]
      },
      {
        "_type": "CodeBlock",
        "_id": "<createId()>",
        "code": "console.log('block nodes need _id')",
        "language": "javascript",
        "fileName": "",
        "compact": false
      }
    ]

    If generating from HTML, Alinea's parser maps common tags to these node/mark types (src/core/field/RichTextField.ts), for example <p> -> paragraph, <a> -> link, <ul>/<ol>/<li> -> list nodes, <strong> -> bold.

    Roots and workspaces

    Config.workspace and Config.root define where content is stored and which root key each entry belongs to (src/core/Workspace.ts, src/core/Root.ts).

    // Example workspace layout in this repository
    content/
      main/
        pages/
          docs.json
          docs/
            reference/
              cli.json
        media/
          screenshot.json
      demo/
        pages/
          index.json
          recipes/
            chocolate-chip.json
        media/
          ...
    
    // Workspace key -> source
    main -> content/main
    demo -> content/demo
    
    // Root key -> folder under each workspace source
    pages -> <workspace>/pages
    media -> <workspace>/media

    Manual generation rules:

    • When creating a root-level entry file, include _root with the matching root key.

    • Place files under the workspace source directory and root directory that match config.

    • For seeded pages, keep _seeded stable and matching the configured seed path.

    • Do not remove nested identity fields (_id, _index, _type) from list rows, union values, or rich text block nodes.

    Validation workflow

    # Normalize metadata via Alinea fix path
    alinea build --fix
    
    # In this repository
    bun run build -- --fix
    
    # Final validation
    bun run build

    Before commit: ensure JSON parses, _type matches schema, _index order is correct among siblings, and no duplicate _id values were introduced in edited scope.