Skip to content

FlatbreadLabs/flatbread

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

273 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Flatbread πŸ₯ͺ

pipeline status Join the Flatbread slack NPM version

Turn flat files in Git into typed, relational content for your TypeScript app. GraphQL and codegen are a common read interface for that content graphβ€”not the only surface you can build; see docs/positioning.md.

Flatbread is a Git-native relational flat-file content layer for TypeScript apps. Your repo and filesystem are the source of truth; plugins (sources, transformers, and resolvers) extend how content is loaded and shaped.

Who it's for: Teams shipping TypeScript sites, internal tools, and starters who want versioned, reviewable content and relationships between entriesβ€”without standing up a CMS database or giving up ownership of where content lives.

Non-goals:

  • Not a hosted CMS, dashboard, or authoring UI: Flatbread is a library and local workflow, not a full content-management product you log into.
  • Not a general-purpose GraphQL platform or a substitute for a general-purpose database (transactions, granular access control, and high-scale multi-writer workloads are out of scope).
  • Reliable live reload of content while the dev server runs is not a supported pillar yet; expect to restart to pick up file changes.

GraphQL: In the default toolkit, GraphQL is a common read interface for the content graphβ€”schema generation and codegen are how many apps reach the data, not the definition of the product. More detail: docs/positioning.md.

Glossary: Quick definitions for collection, relation, ID, cardinality, validation, query interface, and how the generated GraphQL schema / operation types map to those terms (GraphQL as one read path, not the whole product)β€”see docs/glossary.md.

Local dev loop: Codegen watch, schema rebuild, content reload, and framework restart boundaries are documented in docs/local-dev-loop.md.

Portability: Stable JSON snapshot export is available as a core API and documented in docs/json-export.md.

Ownership and exit: Raw files, Git history, JSON/CSV exports, GraphQL introspection, and generated TypeScript all fit one portability story in docs/data-ownership.md.

Roadmap: Current keep/kill/iterate decisions from validation work live in docs/roadmap.md.

For contributing to this monorepo, use Node 20.19+ with pnpm 10.33.x. Runtime support for published packages is tracked by each package's own metadata.

Born out of a desire to Gridsome (or Gatsby) anything, this project harnesses a plugin architecture to be easily customizable to fit your use cases.

Quickstart (posts, authors, and tags)

🚧 This project is experimental; the API may change before v1.0.

This repo’s canonical first success path is the Next.js example (examples/nextjs). It reads shared markdown under examples/content (mounted in that app as content/ via symlink). Commands below are exact for that layout.

1 Β· What you are modeling

  • Collections (Post, Author) map to folders of files; see the glossary.
  • Relations: posts declare authors: in frontmatter as a list of author ids; Flatbread resolves them through refs in config (same idea as joins, over filesβ€”not a remote database).
  • Tags: in the bundled example, each post exposes tags as a YAML string list in frontmatter. That becomes a [String] field on Post in the generated schema. That is facet-style metadata repeated per postβ€”not the same machinery as refs to another collection. If you need normalized tag records shared across posts, model a Tag collection and wire refs yourself (advanced).

Illustrative frontmatter:

---
id: your-post-id
title: Example
authors:
  - author-id-one
tags:
  - typescript
  - content-graph
---

Markdown below the closing --- is the post body.

2 Β· Content layout (this monorepo)

From the repo root, the markdown that backs the relational story lives here:

examples/content/markdown/posts/     # Post collection (incl. example-post.md, …)
examples/content/markdown/authors/   # Author collection

The Next example points flatbread.config.js at content/markdown/... relative to examples/nextjs, where content is the symlink to ../content.

Backing files for posts, authors, and tags (this example):

What Where it lives Glossary terms
Posts examples/content/markdown/posts/*.md β€” one record per file Collection, Record
Authors examples/content/markdown/authors/*.md β€” one record per file Same; IDs in frontmatter wire relations
Tags The tags: YAML list in each post’s frontmatter (facet metadata on that Post). There is no markdown/tags/ directory here. Tag (facet) vs Tag collection

Traceability: same relation model (files, config, query interface)

The table below ties the Git-native model to the default GraphQL read layer without implying GraphQL is the product’s whole identityβ€”GraphQL is one query interface; files and config remain the source of truth.

Layer You see… Glossary
Files authors: ids in a post file match id: in author files; tags: is a string list on the post Relation, ID, Tag (facet) vs Tag collection
flatbread.config.js content entries with collection: 'Post' | 'Author' and refs: { authors: 'Author' } Collection, Relation
Generated GraphQL schema + codegen TS allPosts { tags authors { id name } } β€” refs resolve to Author objects; tags stays a scalar list on Post Generated schema and operation types (GraphQL), Cardinality

Illustrative query result (same relation model as examples/content/markdown/posts/example-post.md: authors 2a3e / 40s3, tags from frontmatter). Values are from that file and its resolved authors; the shape matches the GetPostsAuthorsAndTags operation in Β§3 after you include tags and authors in your .graphql document (see also queries/posts.graphql, which you can extend the same way):

{
  "allPosts": [
    {
      "id": "sdfsdf-23423-sdfsd-23444-dfghf",
      "title": "The Art of Measuring Cats in Fruit Units",
      "tags": ["cats", "measurements", "fruit-science", "important-research"],
      "authors": [
        { "id": "2a3e", "name": "Tony" },
        { "id": "40s3", "name": "Eva" }
      ]
    }
  ]
}

Add tags (and any other fields) to your .graphql documents and rerun codegen so operations and generated/graphql.ts stay aligned with the filesβ€”snippets in docs are illustrative until your checked-in queries match.

3 Β· Run it from the repo root

Prerequisites: Node 20.19+, pnpm 10.33.x (see CONTRIBUTING.md).

pnpm install
pnpm build
cd examples/nextjs
pnpm exec flatbread codegen --verbose

That writes generated/graphql.ts: TypeScript types and typed document nodes for your .graphql operations (configure globs under codegen.documents in flatbread.config.js).

Add a .graphql file (see queries/posts.graphql in the example), then rerun pnpm exec flatbread codegen --verbose so the operation reflects tags, authors, etc. Illustrative operation you can paste into queries/:

query GetPostsAuthorsAndTags {
  allPosts(limit: 5) {
    id
    title
    tags
    authors {
      id
      name
    }
  }
  allAuthors {
    id
    name
  }
}

After codegen, your app imports types from ./generated/graphql. The result shape of that operation is typed (for example GetPostsAuthorsAndTagsQuery)β€”relations resolve to Author objects while tags stay a string array on Post, matching the file metadataβ€”the same row as the illustrative JSON under Traceability.

The generated file also exposes a prototype TypeScript read API derived from the configured content model. In the Next.js example, examples/nextjs/lib/read.ts wires createFlatbreadReadApi() to the existing GraphQL fetcher and reads posts, authors, and tags with a generated default selectionβ€”no hand-written GraphQL document at the call site.

Choosing a read interface

Flatbread starts with Git-native relational content for TypeScript apps: flat files define records, frontmatter fields, ids, and refs; flatbread.config.js tells Flatbread how those files become typed collections. GraphQL is one interface over that typed model, and the generated TypeScript read API is another app-facing surface generated from the same model.

Use GraphQL operations when your app needs explicit query documents, custom selections, Apollo or other GraphQL clients, persisted operations, or direct access to the GraphQL endpoint. Add .graphql documents, include fields like tags and authors, and rerun codegen so operation types such as GetPostsAuthorsAndTagsQuery match the posts/authors/tags graph.

Use the prototype generated TypeScript read API when your app wants collection-shaped helpers for common reads from the configured Flatbread model, especially simple app reads such as posts, authors, tags, and resolved relations without writing GraphQL at each call site. The generated helpers currently execute through the GraphQL layer and still offer an experimental selection-string escape hatch, so both paths expose the same typed content graph backed by the same flat files while GraphQL remains the stable low-level interface.

Default filesystem + markdown wiring uses the bundled source-filesystem and transformer-markdown plugins (flatbread re-exports them).

4 Β· Minimal relational config (mental model)

The example’s production config loads extra collections for tests; the core onboarding shape is:

import { defineConfig, transformerMarkdown, sourceFilesystem } from 'flatbread';

export default defineConfig({
  source: sourceFilesystem(),
  transformer: transformerMarkdown({
    markdown: { gfm: true, externalLinks: true },
  }),
  content: [
    {
      path: 'content/markdown/posts',
      collection: 'Post',
      refs: { authors: 'Author' },
    },
    {
      path: 'content/markdown/authors',
      collection: 'Author',
      refs: { friend: 'Author' },
    },
  ],
});

5 Β· Reading the graph: GraphQL (after the model exists)

Flatbread builds a content graph from files. In the default toolchain, GraphQL is one read interface: schema + resolver shape over that graphβ€”not β€œFlatbread is a GraphQL CMS.”

Wire your framework so the CLI wraps dev/build (flatbread start passes through your command after --). There is no flatbread dev subcommand.

// package.json scripts (adapt the part after `--` to your framework)
{
  "scripts": {
    "dev": "flatbread start -- next dev --turbopack",
    "build": "flatbread start -- next build"
  }
}

In the Next example from examples/nextjs, pnpm dev enables HTTPS locally and pairs Next with Flatbread. The GraphQL HTTP endpoint defaults to http://localhost:5057/graphql; the Next app is on 3000. pnpm run dev here is distinct from next start alone (production Next without Flatbread unless you arrange serving yourself).

pnpm dev

If the server starts cleanly, Flatbread prints the graphql URL. Opening it launches Apollo Studio against the generated schemaβ€”you can iterate on queries there, then freeze them into .graphql files and rerun flatbread codegen.

Live reload of markdown while the process runs is not reliable yetβ€”restart dev after content changes.

Install Flatbread in your own repo

Outside this monorepo:

pnpm add flatbread@latest

Scaffold flatbread.config.js:

pnpm exec flatbread init

Point content entries at your posts/ and authors/ folders, reuse the relational ideas above, and add codegen in config when you want generated/graphql.ts. Browse packages for plugins and resolver helpers.

More detail on the bundled example (scripts, codegen watch, troubleshooting): examples/nextjs/README.md.

Query arguments (GraphQL read interface)

When GraphQL is your read interface, list fields use the following arguments in order of application.

filter

Each collection in the GraphQL schema can be passed a filter argument to constrain your results, sifting for only what you want. Any leaf field should be able to be used in a filter.

The syntax for filter is based on a subset of MongoDB's query syntax.

filter syntax

A filter is composed of a nested object with a shape that matches the path to the value you want to compare on every entry in the given collection. The deepest nested level that does not have a JSON object as its value will be used to build the comparison where the key is the comparison operation and value is the value to compare every entry against.

Example

filter = { postMeta: { rating: { gt: 80 } } };

entries = [
  { id: 1, title: 'My pretzel collection', postMeta: { rating: 97 } },
  { id: 2, title: 'Debugging the simulation', postMeta: { rating: 20 } },
  {
    id: 3,
    title: 'Liquid Proust is a great tea vendor btw',
    postMeta: { rating: 99 },
  },
  { id: 4, title: 'Sitting in a chair', postMeta: { rating: 74 } },
];

The above filter would return entries with a rating greater than 80:

result = [
  { id: 1, title: 'My pretzel collection', postMeta: { rating: 97 } },
  {
    id: 3,
    title: 'Liquid Proust is a great tea vendor btw',
    postMeta: { rating: 99 },
  },
];

Supported filter operations

  • eq - equal
    • This is like filterValue === resultValue in JavaScript
  • ne - not equal
    • This is like filterValue !== resultValue in JavaScript
  • in
    • This is like filterValue.includes(resultValue) in JavaScript
    • Can only be passed an array of values which pass strict comparison
  • nin
    • This is like !filterValue.includes(resultValue) in JavaScript
    • Can only be passed an array of values which pass strict comparison
  • includes
    • This is like resultValue.includes(filterValue) in JavaScript
    • Can only be passed a single value which passes strict comparison
  • excludes
    • This is like !resultValue.includes(filterValue) in JavaScript
    • Can only be passed a single value which passes strict comparison
  • lt, lte, gt, gte
    • This is like <, <=, >, >= respectively
    • Can only be used with numbers, strings, and booleans
  • exists
    • This is like filterValue ? resultValue != undefined : resultValue == undefined
    • Accepts true or false as a value to compare against (filterValue)
    • For checking against a property that could be both null or undefined
  • strictlyExists
    • This is like filterValue ? resultValue !== undefined : resultValue === undefined
    • Accepts true or false as a value to compare against (filterValue)
    • Checking against a property for undefined
  • regex
    • This is like new RegExp(filterValue).test(resultValue) in JavaScript
  • wildcard
    • This is an abstraction on top of regex for loose string matching
    • Case insensitive
    • Uses matcher and matcher's API

Caveats:

Combining multiple filters

You can union multiple filters together by adding peer objects within your filter object to point to multiple paths.

Example

Using the entries from the previous example, let's combine multiple filters.

query FilteredPosts {
  allPosts(
    filter: { title: { wildcard: "*tion" }, postMeta: { rating: { gt: 80 } } }
  ) {
    title
  }
}

Results in:

result = [{ title: 'My pretzel collection' }];

sortBy

Sorts by the given field. Accepts a root-level field name. Defaults to not sortin' at all.

order

The direction of sorting. Accepts ASC or DESC. Defaults to ASC.

skip

Skips the specified number of entries. Accepts an integer.

limit

Limits the number of returned entries to the specified amount. Accepts an integer.

Query from your app

Follow Quickstart (posts, authors, and tags) for the relational model, codegen, and typed results. For framework wiring and scripts, use examples/nextjs or other examples (for example SvelteKit).

Field overrides

Field overrides allow you to define custom GraphQL types or resolvers on top of fields in your content. For example, you could optimize images, encapsulate an endpoint, and more!

Example

{
  content: {
    ...
    overrides: [
      {
        // using the field name
        field: 'name'
        // the resulting type is string
        // this can be a custom gql type
        type: 'String',
        // capitalize the name
        resolve: name => capitalize(name)
      },
    ]
  }
}

Supported syntax for field

  • basic nested objects

    nested.object

  • a basic array (will map array values)

    an.array[]

  • a nested object inside an array (will also map array)

    an.array[]with.object

for more information in Overrides, they adhere to the GraphQLFieldConfig outlined here https://graphql-compose.github.io/docs/basics/what-is-resolver.html

Advanced Config

fieldNameTransform

Accepts a function which takes in field names and transforms them for the GraphQL schema generation -- this is used internally to remove spaces but can be used for other global transforms as well

{
  ...
  // replace all spaces in field names with an underscore
  fieldNameTransform: (fieldName) => fieldName.replace(/\s/g, '_')
  ...
}

β˜€οΈ Contributing

See CONTRIBUTING.md for the release workflow (bumping versions and publishing).

About

Consume relational, flat-file data using GraphQL in any static framework πŸ«“

Topics

Resources

Contributing

Stars

Watchers

Forks

Sponsor this project

  •  

Packages

 
 
 

Contributors