Page-Type Scaffolding Templates

When you create a page type in the CMS, Leed commits starter templates to your content repository so the set renders on the very next build instead of 404ing until you write a template by hand. Those files are starting points you own. They are committed once, they are never touched again, and the moment you edit one it is yours — nothing in the product will come back and reconcile it.

What a new page type actually writes

Creating a page type through the CMS commits three files — and only two for a documentation or API set.

FileWritten whenPurpose
src/<slug>-index.hbsAlwaysThe paginated card grid over the page type’s :paged collection
src/<slug>/<lastSlugSegment>.11tydata.jsonAlways (any page type with a slug)Eleventy directory data holding the page type’s site-builder settings
src/_layouts/<slug>.hbsUnless the page type’s layout is leed-documentation.hbsA minimal layout wrapping your master template

That is the whole list. The authors index and the label index exist as templates but the CMS create path does not write them: POST /api/pagetypes calls createNewPageType(pageType, shouldCreateLayout, false, false), with the authors and labels flags hard-coded false. They are produced only by workspace seeding, which is what builds the starter blog on a brand-new company.

If you want an authors index or a label index, you write it yourself from the shipped template. Both are listed below and both are short.

The CMS Layouts workspace file tree after creating a page type named guides, showing src/guides-index.hbs, src/_layouts/guides.hbs and src/guides/guides.11tydata.json

The same three paths appear in the repository’s history as one commit titled Creating Templates for New PageType - guides.

The eight templates

TemplateWritten toWritten byWhenPurpose
PAGE.hbssrc/<path>.hbscreateTemplatePageWorkspace seeding onlyA single standalone page (the seeded home page)
PAGE_TYPE_INDEX.hbssrc/<slug>-index.hbscreateNewPageTypeAlways, on page-type createPaginated card grid over :paged
PAGE_LAYOUT.hbssrc/_layouts/<slug>.hbscreateNewPageTypeOn create, skipped for leed-documentation.hbsThe page type’s layout
PAGE_TYPE_AUTHORS.hbssrc/<slug>-authors.hbscreateNewPageTypeSeeding only (authors flag)One page per author, over :authors
PAGE_TYPE_LABEL_INDEX.hbssrc/<slug>-topic-index.hbscreateNewPageTypeSeeding only (labels flag)One page per label, over :labels
PAGINATED_PAGE_TYPE_LIST.hbssrc/<slug>-list.hbscreatePaginatedPageTypeListNot reachable from the CMS — copy by handThe driver for a noRender page type
PAGINATED_PAGE_TYPE_ITEM.hbssrc/_includes/<slug>-item.hbscreatePaginatedPageTypeListNot reachable from the CMS — copy by handThe article body for that set
PAGINATED_PAGE_TYPE_PAGINATION.hbssrc/_includes/<slug>-pagination.hbscreatePaginatedPageTypeListNot reachable from the CMS — copy by handFirst / prev / next / last controls
(not a template) .11tydata.jsonsrc/<slug>/<lastSlugSegment>.11tydata.jsoncreate11tyDataAlways, on page-type createGenerated from the page type’s own settings, not from a template file

The last row matters when you go looking for it: the directory data file has no template. It is JSON.stringify of the page type’s site-builder data, so its contents change with the page type’s settings rather than with a file in Leed’s template folder. How that file joins the data cascade is covered in Global Site Data.

PAGE_TYPE_INDEX.hbs

The index paginates collections.pageTypeId-<id>:paged, which is already chunked, so size is 1 — one chunk per output page. Two lines in its front matter are worth reading before you edit it:

{
    "title": "Blog List",
    "publishedAt": "list",
    "modifiedAt": "list",
    "pageTypeId": "100111",
    "pagination": {
        "data": "collections.pageTypeId-100111:paged",
        "size": 1,
        "alias": "list",
        "addAllPagesToCollections": true,
        "generatePageOnEmptyData": false,
        "reverse": false
    },
    "pageId": "3f1a9c04",
    "permalink": "{{ pageType this.pageTypeId 'slug' }}{{#unless list.page.last }}/page/{{ add list.page.number 1 }}{{/unless}}/index.html"
}

generatePageOnEmptyData: false means a page type with no published pages produces no index at all, rather than an empty grid.

The computed permalink is the line to read twice. :paged is chunked from a collection sorted ascending by publishedAt, so chunk 0 holds the oldest pages and page.last is true on the chunk holding the newest. {{#unless list.page.last }} therefore drops the page segment from the newest chunk, which is what makes /blog/ the set’s landing page; the older chunks become /blog/page/1/, /blog/page/2/ and so on, numbered from the oldest. Change that expression and you change every URL in the set, so treat it as a URL decision rather than a formatting one.

The body wraps everything in {{#>site-template}} and iterates {{#each (reverse list.pageData)}} — the reverse is what puts the newest card first within a chunk. The collections and the PageData object it walks are documented at Collections and Pagination Data.

PAGE_LAYOUT.hbs

The layout scaffold is deliberately small — a wrapper, an <h1>, and the content:

{{#>site-template}}

<main class="relative mt-8 mx-6 md:mx-8">
    <section id="template-content">
        <article id="guides-copy">

            <h1>{{{ title }}}</h1>

            <!-- START guides CONTENT-->
            {{{ process content }}}
            <!-- END guides CONTENT-->

        </article>
    </section>
</main>

{{/site-template}}

The shipped file also wraps that body in a pair of START / END Handlebars block comments, omitted here.

The paginated trio

PAGINATED_PAGE_TYPE_LIST.hbs, plus <slug>-item.hbs and <slug>-pagination.hbs in _includes, are the pattern for a page type whose pages your template generates rather than the site builder. Set the page type to Do Not Render (noRender), and the list template takes over every URL in the set:

{
    "pagination": {
        "data": "collections.pageTypeId-100111:unpaged",
        "size": 1,
        "alias": "item",
        "addAllPagesToCollections": true,
        "reverse": false,
        "resolve": "values"
    },
    "forceRender": true,
    "permalink": "{{ pageType this.pageTypeId 'slug' }}/{{ item.data.slug }}/index.html"
}

Three things make it work. forceRender: true overrides the noRender suppression for this one file. The :unpaged collection is a flat list rather than chunks, so size: 1 yields one output page per content page. And the permalink is built from item.data.slug, which is why the template — not Leed — owns the path of every page in the set. noRender and its consequences are covered at Layouts and Page Types.

Reach for this when a set’s URLs cannot be expressed by the standard page-type slug plus page slug: an imported archive that has to keep its legacy paths, or a generated set numbered rather than named.

The full PAGINATED_PAGE_TYPE_LIST.hbs body

Everything after the front matter. Note that it wraps the whole document in {{#with item.data}} so the page renders in the context of the item, not the driver page, and that the two _includes partials are named after the page type’s slug:

{{#with item.data }}

{{#>site-template}}

<main class="relative mt-8 mx-6 md:mx-8">
    {{> guides-pagination }}

    {{> guides-item  }}

    {{> guides-pagination }}
</main>

{{/site-template}}

{{/with}}

guides-item.hbs renders the article, and because it runs inside the {{#with}} scope it reaches back out for the body with {{{ process @root/item.content }}} — the item’s content is not in scope otherwise. guides-pagination.hbs opens with {{#with @root }}, for the same reason: pagination.href.first / .previous / .next / .last live at the root, not on the item.

The tokens

The shipped templates carry ###TOKEN### placeholders. Substitution is literal string replacement performed once, at commit time, so the tokens never appear in your repository — this table is for reading the shipped templates, not for authoring.

TokenResolves to
###PAGE_TYPE_ID###The page type’s id (for example 100111)
###PAGE_TYPE_NAME###The page type’s display name
###PAGE_TYPE_SLUG###The page type’s slug
###NEW_PAGE_ID###A freshly minted UUID v4 for the generated page
###NEW_SHORT_ID###The first 8 characters of that same UUID
###LAYOUT_SLUG###The slug passed to the layout scaffold — used only in PAGE_LAYOUT.hbs
###DATE###The commit moment, as an ISO-8601 timestamp
###TITLE###The page title — used only in PAGE.hbs
###SEPARATOR###The label URL segment, default topic

###SEPARATOR### is why the label index lands at <slug>-topic-index.hbs and why its permalink reads /blog/topic/<label>/. The CMS create path never passes a separator, so it is always topic there.

One placeholder-looking string is not a substitution: ###ROOT###. It is a live sentinel value that stays in the file. A page whose front matter carries "pageTypeId": "###ROOT###" is resolved at build time to whichever page type has the empty slug, which is how Leed’s own unsubscribe pages attach themselves to your site’s root page type without knowing its id.

Where the templates come from

The scaffolds are fetched at runtime over the GitLab API, from Leed’s own repository — project 34227674, path packages/site-management/static/templates/pages, branch main. They are not read from the copy of @leed/site-management you have installed.

Two consequences follow. What you get is whatever is on main at the moment you create the page type, so two page types created six months apart can be scaffolded from different versions of the same template. And pinning your CLI version does not pin your scaffolds; the CLI is not involved in this path at all.

Nothing is ever overwritten

Before every scaffold commit, Leed asks GitLab whether the path already exists on the preview branch (staging). If it does, the file is dropped from the commit. If every file in the batch already exists, no commit is made at all.

That is the guarantee behind the whole page: createNewPageType is safe to call repeatedly, and editing a scaffolded file is safe forever. It also means that if you delete a scaffold and want it back, the way to get it is to copy the shipped template again by hand — recreating the page type will not reinstate it, because the other two paths still exist and the create path does not inspect the missing one.

One naming rule to know before you go looking for the directory data file. It is named after the last segment of the page type’s slug, not the whole slug: a page type at docs/api/3.7.0 gets src/docs/api/3.7.0/3.7.0.11tydata.json. Eleventy binds a directory data file to its own directory by filename, so interpolating the full slug would produce src/docs/api/3.7.0/docs/api/3.7.0.11tydata.json — bound to an empty phantom directory, and inherited by nothing.

Creating the page type in the first place, and every field on that screen, is at Configuring a Page Type. These commits land on the preview branch, so they reach your preview site before your live one — see Preview Site vs Live Site. Everything else Leed writes into your repository, from logos to managed build scripts, is inventoried at What Leed Writes Into Your Repo.

ESC