Static site generation with typed content + dynamic Svelte components at build time.
Composably is a content processing plugin for Vite and SvelteKit. It extends the build process by discovering, validating, and transforming content from Markdown, YAML, JSON, or dynamic modules. The processed content is made available in your application as a virtual module. This is useful for SSG flows similar to Astro, Hugo, or Jekyll.
The API is currently a bit unpolished, but the library is a showcase of itself and everything
outside src/lib
is set up as a consumer example.
There is also an integration test src/tests/composably.spec.svelte.ts
that describes most
of the functionality. Feel free to clone and hack.
Like MDsveX, this package enables content authors to embed components in markdown, but MDsveX does this by extending the component syntax with a preprocessor to support markdown, effectively treating markdown documents as components. In contrast, Composably treats markdown as a data type just like strings, dates, or arbitrary shapes. This facilitates a data-driven workflow and enforces a stricter separation of code and content which can be more intuitive for some SSG patterns.
Create a new SvelteKit project or cwd into an existing project with Svelte 5. Then install composably:
pnpm install composably
In your vite.config.ts
, import the plugin and pass a configuration object
specifying the root directories for your content and components.
...
import { composably } from 'composably/vite';
const config = {
componentRoot: 'src/components',
contentRoot: 'src/content',
};
export default defineConfig({
plugins: [
composably(config),
...
]
});
Create a content file at src/content/index.md
. When the content format is a markdown file
with frontmatter, as in this example, the markdown section will be added as body
next to the
other fields.
---
component: MyPageComponent
title: The index page
---
# Hello world
This is `src/content/index.md`, the root of the site.
`title` is available here: {{title}}.
:tada:
See component
in the frontmatter? It's a keyword that tells Composably the content is for
src/components/MyPageComponent.svelte
. The rest of the properties (title
and body
in this case) are exactly the props expected by the component:
<script module>
import { c } from 'composably/schemas';
export const schema = c.content({
title: c.string(),
body: c.markdown()
});
</script>
<script>
let { title, body } = $props();
</script>
<h1>{title}</h1>
<body.component {...body} />
As you can see, title
is treated like an ordinary string, but body
has been transformed to a
dynamic component, how is this achieved?
The markdown()
type in the schema instructs Composably to parse the field's content to html
and make a virtual module from it. This is a consequence of a general procedure that looks like this:
composably:content
.This export can be used to map paths to page data in routes/[...path]/+page.js
[^2]:
[^2]: While placing [...path]/+page.js
at the root (e.g., src/routes/
) enables full-site
SSG-like behavior with Composably, you can also scope it to sub-routes
(e.g., src/routes/blog/[...path]/
) for partial SSG. Furthermore, the load
function in
+page.js
isn't restricted to just passing data from content()
; you're free to add custom
logic to transform or augment this data before it reaches your Svelte components.
import content from 'composably:content';
export const load = async ({ params }) => {
return await content(params.path);
};
The content data has a shape that makes it convenient to render, here's a minimal
example of routes/[...path]/+page.svelte
:
<script>
let { data } = $props();
</script>
<data.component {...data} />
The result:
<h1>The index page</h1>
<h2>Hello world</h2>
<p>
This is <code>src/content/index.md</code> <code>title</code> is available
here: The index page.
<span role="img" aria-label="tada emoji">🎉</span>
</p>
This completes the basic workflow, showing how content is defined, processed by Composably,
loaded in a SvelteKit route, and finally rendered by a Svelte component. You can now create
more content files in src/content/
(e.g., about.yaml
) and they will be routable
(e.g., at /about
) by the same mechanism.
The heart of Composably is the build-time pipeline that analyzes content and inserts virtual modules while keeping track of file dependencies for Hot Module Replacement. Features like markdown transformation, validation, slots, fragments, etc., are merely plugins to this pipeline and can easily be customized for various needs. Here's a list of the current feature set:
Interpolate values from the content file's frontmatter into any field
processed with c.markdown()
. Composably uses double brace syntax ({{title}}
) for this
to relieve authors from the inconvenience of escaping single braces in plain text.
---
component: ArticlePage
title: Understanding Interpolation
kicker: A Short Guide
author: Jane Doe
---
### {{kicker}}
# {{title}}
_By {{author}}_
However, to prevent hard-to-debug circular dependencies and avoid the complexity of interpolating
large Markdown blocks into each other, other fields from the same content source that are also
typed as c.markdown()
are excluded from this interpolation.
Structure complex pages by nesting components.
component: SimplePageLayout
title: Page with Card
card: # This field holds data for a Card component
component: Card
heading: Welcome!
content: This is a card component content.
Use the c.component()
type to expect data for another component.
<script module>
import { c } from 'composably/schemas';
export const schema = c.content({
title: c.string(),
card: c.component(['Card']) // Expects data for a 'Card' component
});
</script>
<script>
let { title, card } = $props();
</script>
<h1>{title}</h1>
<div class="card-container">
<card.component {...card} />
</div>
Calling c.component()
without arguments will allow any component,
while passing a list of names c.component(['Name1', 'Name2'])
restricts to specific components.
Define embeddable components in your frontmatter using slots
. Each key under slots:
defines
a handle for use in Markdown, so carousel
key below is picked up by ::carousel
markdown directive.
Markdown fields processed with c.markdown()
(like body
in the example) will automatically find
these ::
directives and replace them with the rendered slot component.
---
component: Page
title: Embedding with Slots
slots:
carousel:
component: Carousel
slides: # Props for the slot
- image: /img1.jpg
- image: /img2.jpg
---
Intro text.
::carousel
Text continues...
The c.slots()
type is used to validate the structure of data provided for a slot.
It accepts an optional array of allowed component names, just like c.component()
.
import { c } from 'composably/schemas';
export const schema = c.content({
title: c.string(),
body: c.markdown(), // Processes ::carousel using 'slots' data
slots: c.slots(['Carousel'])
});
Create src/components/Carousel.svelte
with its schema (slides: c.array(...)
).
Composably's c.markdown()
processor replaces ::carousel
with the rendered Carousel component.
Reference content fragments in frontmatter by prefixing the data key with an underscore (_author:
).
The value is the path to the fragment file (people/_jane.yaml
).
Fragment files (people/_jane.yaml
) typically also start with _
to prevent direct routing.
# src/content/people/_jane.yaml
component: AuthorBio
name: Jane Doe
bio: Expert writer exploring Composably.
Reference it in another content file:
---
component: BlogPost
title: Jane's post
_author: people/_jane.yaml
---
Jane's blog content here...
The component receives the page with all fragments resolved and attached.
<script module>
import { c } from 'composably/schemas';
export const schema = c.content({
title: c.string(),
author: c.component(['AuthorBio']), // Validate the linked fragment data against AuthorBio schema
body: c.markdown()
});
</script>
<script>
let { title, author, body } = $props();
</script>
<article>
<h1>{title}</h1>
<p>By <author.component {...author} /></p>
<body.component {...body} />
</article>
The headings are decreased one step (H1
-> H2
) and extracted for a TOC.
Disable decreasing by passing the option to the markdown type:
c.markdown({ decreaseHeadings: false })
.
{#if body.headings && body.headings.length > 0}
<nav>
<strong>On this page:</strong>
<ul>
{#each body.headings as heading}
<li><a href="#{heading.id}">{heading.text}</a></li>
{/each}
</ul>
</nav>
{/if}
<body.component {...body} />
The default validator can be replaced by setting config.validator
to a validator function,
if you, for example, prefer to centralize your schemas. Here's the contract:
export const myPassthruValidator = async (
content: SourceComponentContent,
reportFileDependency: (filePath: string) => void,
config: Config
): Promise<SourceComponentContent> => {
return content;
};
Composably's markdown parser includes standard Markdown, GitHub Flavored Markdown, syntax highlighting for code blocks, heading extractions, definition lists and extended tables. Need more? Composably integrates with the Remark (Markdown AST) and Rehype (HTML AST) plugin ecosystems. Add plugins to:
Follow these steps to set up the project locally for development or testing:
Clone the repository:
git clone https://github.com/kompismoln/composably
cd composably
Install dependencies:
pnpm install
Start the development server:
💡 Set
DEBUG=composably*
for verbose logging duringpnpm run dev
,pnpm run test
, orpnpm run build
.
This runs the example site included in the repository, using the local version of the plugin.
pnpm run dev
Run tests:
pnpm run test # Runs unit tests once
pnpm run test:unit # Runs unit tests in watch mode
Check code quality:
pnpm run format # Formats code using Prettier
pnpm run lint # Lints code using ESLint
pnpm run check # Runs svelte-check for type checking
Build the package:
This compiles the plugin code into the /dist
directory.
pnpm run build
If you use Nix, you can enter a reproducible development shell with all required dependencies activated:
nix develop