Skip to content

Project sections and settings

A project section is a top-level project.json key owned by an extension class: content (parser), connections and data (connector), auth (auth). Owning a section means three things: your fragment defines its schema, your capabilities load it and expand its $paths , and your $studio block gives it a settings page. A class claims a section iff its descriptor has a top-level project object.

The project block

The parser's Content.class.json declares, verbatim:

"project": {
  "key": "content",
  "title": "Content Types",
  "description": "File-based content collections loaded at build and dev-serve time",
  "referenceable": true
}
Key Type Default Meaning
key string — The project.json top-level property this class owns (single word by convention; exclusive across extensions).
title string — Studio label.
description string — Studio help text.
referenceable boolean false Opts the section's named entries into the relationships vocabulary ( Relationships ).

The section's value schema is not duplicated here . It lives in the package's project fragment (the manifest's schemas.project , see Schema composition ). Hosts that need the entry shape (Studio settings forms, reference pickers) read properties[<key>] from the fragment via the registry.

Loading section data: projectData

Behavior attaches through capability methods on the same class. The projectData capability turns the raw section value into loaded data:

projectData(sectionValue, { projectConfig, root, registry, io, lazy? }) → unknown

The compiler's site build and the dev server's resolve path both call it, storing the result as _project[<key>] in resolved scope, so the parser's projectData loads content types through the format registry and pages see the entries as config._project.content . The auth extension's projectData is nearly a passthrough: it exposes the section's identifiers under _project.auth (never secrets).

Loading on demand

A build reads every part of every section, so it calls projectData without lazy and gets everything at once. The dev server and Studio serve one page at a time, so they pass lazy: true . An owner may then return data that loads each part the first time a class asks for it, with the same shape a whole load has, plus a complete() method that loads the rest. An owner that ignores lazy returns whole data, and every host reads it unchanged.

The parser's content section uses this. With lazy , _project.content starts empty: a collection loads the first time it is asked for, with only its frontmatter, and an entry's Markdown body is parsed only when that entry is returned. A page that shows four case studies parses four bodies instead of every collection in the project.

A class written against a whole section still gets one. Unless its implementation declares static projectOnDemand = true , the server calls each section's complete() before constructing it, so a synchronous _project.content.get("posts") sees every entry with its body, as in a build. To get the on-demand speed in your own class, read the section the way ContentCollection does and declare the flag:

export class LatestPosts {
  static projectOnDemand = true;

  constructor(config) {
    this.config = config;
  }

  async resolve() {
    const content = this.config._project?.content;
    const posts = (await content?.load?.("posts")) ?? content?.get("posts") ?? [];
    const latest = posts.slice(0, 3);
    await content?.materialize?.(latest);
    return latest;
  }
}

load and materialize are optional, so the same code reads a whole section (a build, or a plain Map in a test) unchanged.

Expanding routes: resolvePaths and discriminators

A dynamic route's $paths value is dispatched to the section class whose resolvePaths capability declares the matching discriminator , the key that routes to it. The parser's discriminator is contentType , so this page head:

{ "$paths": { "contentType": "blog", "param": "slug" } }

dispatches to Content.resolvePaths(pathsDef, { data, projectConfig, root }) , which returns one route-param object per entry ( [{ "slug": "hello-world" }, …] ). Hosts dispatch purely on which discriminator key is present: no central switch statement, no extension aware of any other. The contributed $paths shape is also what your document fragment unions into the paths schema resource, so editors validate it. See Routing for the authoring side.

Publishing files: assets

If your section reads from directories, the assets capability publishes them at a site URL so the files beside its sources are reachable:

assets(sectionValue, { projectConfig, root }) → [{ urlPrefix: "/content/blog", dir: "/abs/content/blog" }]

The parser returns one mount per content type with a directory source. The site build resolves those URLs for image optimization and copies the referenced files into dist/ ; the dev server serves them from the source. That is what makes a collection's co-located images work, including when the source lives outside the project. Details in Capability methods .

Studio settings: $studio.settings

The section class's $studio block may declare a settings section, rendered generically by Studio in its Project settings dialog. The auth extension's, verbatim:

"$studio": {
  "settings": {
    "icon": "lock-simple",
    "label": "Authentication",
    "order": 58,
    "layout": "form",
    "entry": {
      "ui": {
        "connection": { "enum": { "$ref": "#/$context/connections" } },
        "secretEnv": { "control": "secret" }
      }
    }
  }
}
Key Meaning
icon Section icon in the settings nav. Names a glyph from the Jx UI kit icon set ( @jxsuite/ui/icons ), never an element tag. A name the kit does not ship draws nothing.
label Section label (defaults to project.title ).
group Which group of the settings nav lists the section: "general" , "integrations" (default) or "advanced" (Studio marks it For developers ). Any other value reads as the default.
order Sort position within its group.
layout "form" (default) renders one form over the whole section value. "map" gives master-detail for type: object + additionalProperties sections: key list left, entry form right.
entry.ui Per-field control overrides for the entry form: { "<field>": { "control": "<name>" } } .
entry.newEntry Template for freshly created entries, with ${key} substitution.
renderer Escape hatch: names a Studio-registered custom section renderer. First-party extensions use the generic path.

Everything else (field labels, types, required marks) comes from the project fragment's schema for the section, so the form stays in sync with validation for free.

The parser's Content class shows the map layout: its section is a map of content types, so Studio renders a key list (add/rename/delete, slugified) beside an entry form, seeds new entries from entry.newEntry ( "source": "./content/${key}/" , where ${key} becomes the new entry's name), and swaps the schema field for the visual schema-builder control. That is the entire implementation of the Content types surface. No parser-specific Studio code exists.

Controls and dynamic enums

Built-in controls you can name in entry.ui :

  • "schema-builder" : the visual JSON-Schema field editor.

  • "secret" : the value is committed via the platform's secret store, never project.json.

  • "binding" : signal/route-param binding.

  • Implicit defaults per type, enum, and format cover everything else.

Enum choices may be dynamic via { "$ref": "#/$context/<pointer>" } , a JSON-pointer walk over the project config (with {@param} segment substitution and the $formats virtual root). Auth's connection field above lists the keys of the project's connections section; #/$context/auth/roles would list configured roles.

Warning

Secrets never enter project.json . Committed config carries identifiers and env-var names only ( "secretEnv": "BETTER_AUTH_SECRET" ); the secret control writes actual values to the platform's secret store, and locally they live in the git-ignored .dev.vars . See Security and secrets .