Content from files
Bricks with source files read Markdown, YAML, JSON and CSV from content/. File layout, frontmatter, data files, drafts, old addresses, links and errors.
What files are for
A brick with source: 'files' reads its entries from files in the repo: Markdown, YAML, JSON or CSV. Use it for content that developers maintain: docs, a blog, legal pages, a price list. These docs use it themselves.
The site reads file content the same way as platform content: with useGinko and useGinkoEntry. At build time, Ginko reads the files and builds the same bundle as the platform does. Visibility, order, addresses, locales, and fallback work unchanged.
A brick has exactly one source. The platform never sees file bricks. A site whose bricks all come from files never asks the platform and needs no platform address.
Set it up
You need the package @lupinum/ginko-content in the dependencies of the site. If it is missing, the build stops with a message that says what to install.
You describe the brick like any other and set source: 'files'. The text of the file fills a field of type f.markdown:
import { collection, defineSite, f } from '@lupinum/ginko'
export default defineSite({
site: 'ginko-docs',
name: 'Ginko',
url: 'https://ginko.lupinum.com',
timeZone: 'Europe/Vienna',
locales: ['de', 'en'],
currency: 'EUR',
pages: { home: { de: '/', en: '/en' } },
bricks: {
docs: collection({
label: 'Docs',
source: 'files',
max: 500,
order: 'manual',
route: { de: '/doku/[slug]', en: '/en/docs/[slug]' },
fields: {
title: f.text('Title'),
description: f.longtext('Description', { optional: true }),
slug: f.slug('Address', { from: 'title' }),
body: f.markdown('Text'),
},
}),
},
})These rules apply to file bricks:
- A file brick has at most one Markdown field: the text of the file. It is not allowed in a list there.
profilesets what the text may contain:inline,basic,rich(default) orpage(also components such as::callout). Raw HTML stays text.- A slug field must be called
slug.
File layout
The files live in the folder content/ next to nuxt.config.ts:
content/
docs/ # collection "docs"
1.einstieg.md # main locale. The number is the order.
1.einstieg.en.md # translation: same name plus .en
2.bausteine.md
rechtliches.md # single "rechtliches"
rechtliches.en.md- A collection has a folder
content/<brick>/. Each file is one entry. - A single is one file
content/<brick>.md. - The file of the main locale has no locale code. A translation has the same name and ends in
.<locale>.md, for example.en.md. - The number before the name is the order. Files without a number come after them, sorted by name. The number sets the order for
order: 'manual'. - The id of an entry is the path without number, locale, and extension, for example
docs/einstieg. - Images and files live in the folder
public/.
A translation without a file in the main locale is an error.
Frontmatter
The frontmatter is a YAML block at the start of the file. It fills the fields of the brick in a simple form. The text below the frontmatter fills the Markdown field. The Markdown field itself never appears in the frontmatter.
---
title: Getting started
description: What Ginko is.
---
## What Ginko is
Ginko connects a Nuxt site with its content.This is how you write each type:
| Type | Form in the frontmatter |
|---|---|
text, longtext | Text. The locale comes from the file name. |
number, boolean | 42 or true |
money | Amount as a number: 12.50 |
date, time | 2026-10-12 and 18:30 |
daterange | from: 2026-10-12 and to: 2026-10-14, indented under the field name |
choice | the value of the option |
choices, list | a YAML list. A list row is an object with the columns. |
image | src (path under public/) and alt, indented under the field name |
file | path under public/ |
ref, refs | the entry id <brick>/<name> (file name without number, locale and extension), e.g. authors/jane; refs as a YAML list |
These rules apply to translations:
- A translation file contains only fields that are translatable. A number field in the file
*.en.mdis an error. - For an image, the translation gives only
alt, notsrc. - A list in the translation must not have more rows than the list in the main locale. Ginko matches rows by their position.
- Translations are always current. There is no "outdated". If a translation is missing, the fallback of the brick applies (
untranslated).
Slug. Without a value in the frontmatter, Ginko takes the file name without the number, for example einstieg for 1.einstieg.md. A translation may set its own slug with slug:. Two entries with the same slug in one locale are an error.
Data files (YAML, JSON, CSV)
A brick without a Markdown field can also read YAML, JSON and CSV files. Use them for structured content without running text, for example a price list or a team.
YAML and JSON. One file is one entry, like a Markdown file. The whole file is what the frontmatter is in Markdown: the same keys, the same simple forms, the same rules for translations. File names follow the same layout: content/menu/1.kaffee.yml, its translation content/menu/1.kaffee.en.yml, a single content/contact.json. In these examples, as on this site, the main locale is de.
# content/menu/1.kaffee.yml
title: Kaffee
price: 3.5
vegan: trueThe same in JSON, as content/menu/2.tee.json:
{
"title": "Tee",
"price": 2.8,
"vegan": true
}CSV. One file holds many entries, one per row. CSV works for collections only. The first row names the columns, for example in content/menu/preise.csv:
slug,locale,title,price,vegan
kaffee,,Kaffee,3.5,true
tee,,Tee,2.8,true
kaffee,en,Coffee,,- The column
slugis required. It is the name of the entry (the id ismenu/kaffee) and its slug. - The column
localeis optional. An empty cell means the main locale. A row withenis the translation of the row with the same slug. - The other columns are field names. An empty cell means "no value".
- Numbers use a point:
3.5, not3,5. Yes/no values aretrueorfalse. - Lists, images,
choicesanddaterangedo not fit in a cell. Use a YAML or Markdown file for them. - The separator is a comma, or a semicolon when the first row has more semicolons (as Excel writes it in German). A cell in double quotes may contain the separator, line breaks and
""for a quote. - The rows keep their order. One folder may mix CSV, YAML and Markdown files.
Drafts
draft: true in the frontmatter (or in a YAML or JSON file, or a draft column in CSV) keeps an entry out of nuxt build, nuxt generate and ginko check. In dev mode, the entry is shown, and the site can mark it with entry.data.draft:
---
title: Spring opening
draft: true
---
Not ready yet.<span v-if="'draft' in entry.data && entry.data.draft">Draft</span>A collection that lists its entries newest first by a date (order: { field: 'date', dir: 'desc' }, like posts()) also schedules entries: an entry dated after the day of the build is left out like a draft, and the first build on or after its date publishes it. This holds for nuxt build too, because file content is part of the build: to publish on the day, rebuild the site every day (for example with a scheduled deploy hook). In dev mode, scheduled entries are shown. A list from the oldest date (events) is not affected: its coming dates are the point.
Old addresses (redirectFrom)
When an entry gets a new slug, its old address should still work. redirectFrom in the frontmatter of the main-locale file lists the old paths. Each one answers with a 301 redirect to the entry's address:
---
title: Getting started
redirectFrom:
- /docs/start
- /docs/first-steps
---A single path is also fine: redirectFrom: /docs/start. Each path starts with /. Redirects that do not belong to one entry go in redirects in ginko.config.ts. On a site whose bricks all come from files, the build also writes the exact paths as route rules, so they work on a static host.
Show Markdown
<GinkoMarkdown> shows the value of an f.markdown field. The Ginko module registers the component in every site.
<script setup lang="ts">
import Callout from './Callout.vue'
const route = useRoute()
const doc = useGinkoEntry('docs', () => route.params.slug)
</script>
<template>
<article>
<h1>{{ doc.data.title }}</h1>
<GinkoMarkdown :value="doc.data.body" :components="{ callout: Callout }" />
</article>
</template>The parser runs at build time. There is no parser at runtime. Ginko highlights code in code blocks with Shiki. Code sits on a dark panel; its colours come from the catppuccin-mocha theme. Ginko knows these languages: typescript, javascript, json, vue, bash, markdown, yaml, html, and css. Other languages appear without color.
Components. In Markdown, you write a component as a block with two colons:
::callout{type="warning"}
An error in a file stops the build.
::Ginko stores the block as an element callout. The site decides with :components which Vue component belongs to it. The package @lupinum/ginko-components brings ready-made components such as callout, kbd and badge. How to add your own is in Add your own component.
Table of contents. In the bundle of a page, a Markdown field holds only a reference. <GinkoMarkdown> fetches the text of the current page. So the page carries only the texts that it shows. The table of contents toc comes with the loaded text. It holds the headings of levels 2 and 3. Each link has id and text:
const tree = await useGinkoMarkdown(() => doc.value.data.body)
const toc = computed(() => tree.value?.toc?.links ?? [])Images
An image can sit next to the Markdown file and be referenced with a relative path. Ginko reads the size from the file, stores the image under a name from its content (/_ginko/img/<hash>.jpg, cached for a year) and sets width and height, so the page does not shift while loading.

The path must stay inside content/; if the file is missing, the build fails with the file and path. Paths starting with / look in public/ (only the size is added); https:// images stay unchanged. Image fields render with <GinkoImage :image="entry.data.hero" />, with size and loading="lazy" (eager for the first image on the page).
Image sizes. On build, Ginko writes WebP copies 480, 960 and 1600 pixels wide, each only when the image is wider. Markdown images and <GinkoImage> list them in srcset, so phones download a small file. SVG and GIF images stay as they are.
Image format. {aspect="4:3"} after an image shows it in that format: cropped around its focus point (for images from the Studio file library, otherwise around the middle), or whole with fit="contain". In the Studio this is “Format” and “Füllend / Eingepasst” (Fill / Fit) in the image's menu.
{aspect="4:3"}Links between pages
In Markdown, link to an entry with $<brick>.<name> and to a page of pages with $page.<key>. The name is the entry's id without the brick, for example einstieg for docs/einstieg; its slug works too. Ginko writes the address of the current locale at build time, so the link stays right when a slug or a route changes. An anchor is kept:
See [what Ginko is]($docs.einstieg#what-ginko-is) and [the start page]($page.home).On every build and on ginko check, Ginko checks the links in all Markdown fields:
| Check | What it finds | Default |
|---|---|---|
broken | a $… link to an entry or page that does not exist; a /… path that matches no entry, page or file in public/ (on sites whose bricks all come from files) | 'error' |
anchors | a #anchor that is not a heading on the target page | 'warn' |
redirected | a link to an old address that redirects | 'warn' |
external | an http(s):// link whose server answers 404 or 410, whose host does not exist, or that times out twice | 'off' |
'error' stops the build, 'warn' prints the problem, 'off' skips the check. You set the levels with linkCheck in ginko.config.ts:
export default defineSite({
// …
linkCheck: { anchors: 'error', external: 'warn' },
})The external check asks the servers over the network, on nuxt build, nuxt generate and ginko check, never in dev mode. More on it in Platform and CLI.
Errors
An error in a file stops the build. The message starts with [ginko] Errors in content/: and names the file and the field; for a CSV row or an unreadable file, also the line. For example:
[ginko] Errors in content/:
- content/docs/1.einstieg.md (colour): Unknown field “colour”.
- content/menu/preise.csv:3 (price): Column “price”: “3,5” is not a number. Use a point as the decimal separator (12.5, not 12,5).These are examples:
- a frontmatter key that the brick does not know
- a locale code in the file name that the site does not have
- a translation without a file in the main locale
- two entries with the same slug in one locale
- an image or file that is missing under
public/ - a value that the rules of the field reject, for example a title that is too long
- text below the frontmatter although the brick has no Markdown field
- a YAML, JSON or CSV file that cannot be read, or a CSV cell that does not fit its field
- in a
```md previewblock, Markdown that the field does not allow, for example::scriptor ajavascript:link
[ginko] Errors in content/ (these files are missing from the site):) and leaves the file out. So check the log when a page is missing.