Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions example/content/2025-08-03-multi-site-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
title: Multi-Site Support in Marmite
slug: multi-site-support
tags: features, docs, content-organization
author: rochacbruno
---

# Multi-Site Support in Marmite

Marmite now supports generating multiple independent sites from a single project! This feature allows you to create subsites with their own themes, configurations, and search indexes.

## How it works

Any directory inside your `content` folder that contains a `site.yaml` file will be processed as a subsite. Each subsite:

- Has its own independent configuration
- Can use a different theme
- Maintains a separate search index
- Is served at its own URL path (e.g., `/site1/`)

## Creating a subsite

1. Create a directory in your content folder:
```bash
mkdir content/site1
```

2. Add a `site.yaml` configuration file:
```yaml
name: My Subsite
theme: theme_template
enable_search: true
```

3. Add content files (markdown) to the subsite directory

4. Build your site as usual - Marmite will automatically detect and process the subsite

## Example structure

```
example/
├── marmite.yaml # Main site config
├── content/
│ ├── posts.md # Main site content
│ └── site1/
│ ├── site.yaml # Subsite config
│ ├── about.md # Subsite content
│ └── posts.md # More subsite content
└── site/ # Generated output
├── index.html # Main site
└── site1/
└── index.html # Subsite at /site1/
```

## Configuration inheritance

Subsites inherit certain configuration values from the parent site if not specified:

- Theme selection
- Date format settings
- Other default values

## Use cases

- Documentation sites with versioned content
- Multi-language sites
- Separate blogs or sections with different themes
- Project portfolios with independent sections

This feature enables complex site architectures while maintaining Marmite's simplicity and performance.
29 changes: 29 additions & 0 deletions example/content/site1/2025-01-01-post.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
title: Welcome to Site1
tags: subsite, announcement
author: Site1 Admin
---

# Welcome to Site1

This is the first post on our independent subsite! This site has its own theme, configuration, and search index.

## What makes this subsite special?

- Independent theme configuration (using theme_template)
- Separate search index
- Own URL space at `/site1/`
- Isolated content management

### Testing features

This subsite demonstrates Marmite's multi-site capabilities.

![A photo of a Bruno](./media/bruno.jpg)


### A photo from the main site

Using `../` to go up one level to the main site.

![A photo of marmite](../media/marmite.jpg)
3 changes: 3 additions & 0 deletions example/content/site1/about.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Hello from site1

This is a test of the site1
Binary file added example/content/site1/media/bruno.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions example/content/site1/media/gallery/site1photos/gallery.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: "Site 1 Photos"
ord: asc
cover: "window.jpg"
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
74 changes: 74 additions & 0 deletions example/content/site1/shortcode-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
title: Shortcode Test for Subsite
date: 2025-01-02
author: Site1 Admin
tags: [subsite, testing, shortcodes]
---

# Shortcode Test

This is a test to verify that shortcodes work correctly in subsites and only show subsite-specific content.

## Tags in this subsite

<!-- .tags -->

## Tags in main site

<!-- .tags site="main" -->

## Authors in this subsite

<!-- .authors -->


## Authors in main site

<!-- .authors site="main" -->

## Recent posts in this subsite

<!-- .posts items=2 -->

## Recent posts in main site

<!-- .posts items=2 site="main" -->


## Card in this subsite

<!-- .card slug=welcome-to-site1 -->

## Card for author in this subsite

<!-- .card slug=author-site1-admin -->

## Card for author from main site

<!-- .card slug=author-marmite site="main" -->


## Card for tag in this subsite

<!-- .card slug=tag-subsite -->


## Card for tag from main site

<!-- .card slug=tag-markdown site="main" -->


## Gallery in this subsite

<!-- .gallery path=site1photos -->

## Gallery from main site

<!-- .gallery path=summer2025 site="main" -->

## Table of Contents in this subsite

<!-- .toc -->


---
4 changes: 4 additions & 0 deletions example/content/site1/site.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
name: A separate site
theme: theme_template
enable_search: true
fragments_fallback: main
6 changes: 3 additions & 3 deletions example/shortcodes/authors.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{# Display a list of all authors with post counts. Params: ord=desc, items=0 #}
{% macro authors(ord="desc", items=0) %}
{% set author_map = group(kind="author", ord=ord, items=items) %}
{# Display a list of all authors with post counts. Params: ord=desc, items=0, site=current #}
{% macro authors(ord="desc", items=0, site="") %}
{% set author_map = group_from_site(kind="author", ord=ord, items=items, site=site) %}
<ul class="authors-list">
{% for author_name, posts in author_map %}
{% set author_slug = author_name | slugify %}
Expand Down
4 changes: 2 additions & 2 deletions example/shortcodes/card.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{# Display a card for content based on slug with optional parameter overrides #}
{% macro card(slug, image="", title="", text="", content_type="") %}
{% macro card(slug, image="", title="", text="", content_type="", site="") %}
{% if slug is starting_with("http://") or slug is starting_with("https://") %}
{# External URL - skip data lookup and use provided parameters #}
{% if image %}
Expand All @@ -26,7 +26,7 @@
{% set target_blank = true %}
{% else %}
{# Internal content - perform data lookup #}
{% set data = get_data_by_slug(slug=slug) %}
{% set data = get_data_by_slug_from_site(slug=slug, site=site) %}
{% if image %}
{% set final_image = image %}
{% else %}
Expand Down
10 changes: 5 additions & 5 deletions example/shortcodes/gallery.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{# Display a gallery of images #}
{% macro gallery(path, ord="", width="100%", height="100%", name="", cover="") %}
{% set gallery_data = get_gallery(path=path) %}
{% macro gallery(path, ord="", width="100%", height="100%", name="", cover="", site="") %}
{% set gallery_data = get_gallery_from_site(path=path, site=site) %}

{% if gallery_data %}

Expand Down Expand Up @@ -30,7 +30,7 @@ <h3 class="gallery-title">{{ gallery_name }}</h3>
{# Main image panel #}
<div class="gallery-main-panel" style="position: relative; width: {{ width }}; height: {{ height }}; margin: 0 auto; overflow: hidden; background: #f0f0f0; display: flex; align-items: center; justify-content: center;">
<img id="gallery-{{ path }}-main-image"
src="{{ url_for(path=site_data.site.media_path ~ '/' ~ site_data.site.gallery_path ~ '/' ~ path ~ '/' ~ cover_image) }}"
src="{{ url_for_from_site(site=site, path=site_data.site.media_path ~ '/' ~ site_data.site.gallery_path ~ '/' ~ path ~ '/' ~ cover_image) }}"
alt="{{ gallery_name }}"
style="max-width: 100%; max-height: 100%; object-fit: contain; cursor: pointer;"
onclick="zoomImage('gallery-{{ path }}')"
Expand All @@ -47,8 +47,8 @@ <h3 class="gallery-title">{{ gallery_name }}</h3>
<div class="gallery-thumbnail-scroll" id="gallery-{{ path }}-scroll" style="overflow-x: hidden; white-space: nowrap; scroll-behavior: smooth; margin: 0 40px;">
<div class="gallery-thumbnail-wrapper" style="display: inline-flex; gap: 5px;">
{% for item in sorted_files %}
<img src="{{ url_for(path=site_data.site.media_path ~ '/' ~ site_data.site.gallery_path ~ '/' ~ path ~ '/' ~ item.thumb) }}"
data-full="{{ url_for(path=site_data.site.media_path ~ '/' ~ site_data.site.gallery_path ~ '/' ~ path ~ '/' ~ item.image) }}"
<img src="{{ url_for_from_site(site=site, path=site_data.site.media_path ~ '/' ~ site_data.site.gallery_path ~ '/' ~ path ~ '/' ~ item.thumb) }}"
data-full="{{ url_for_from_site(site=site, path=site_data.site.media_path ~ '/' ~ site_data.site.gallery_path ~ '/' ~ path ~ '/' ~ item.image) }}"
data-description="{{ item.description | default(value='') }}"
alt="{{ item.image }}"
style="width: 50px; height: 50px; object-fit: cover; cursor: pointer; border: 2px solid transparent;"
Expand Down
6 changes: 3 additions & 3 deletions example/shortcodes/posts.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{# Display a list of recent posts. Params: ord=desc, items=10 #}
{% macro posts(ord="desc", items=10) %}
{% set post_list = get_posts(ord=ord, items=items) %}
{# Display a list of recent posts. Params: ord=desc, items=10, site=current #}
{% macro posts(ord="desc", items=10, site="") %}
{% set post_list = get_posts_from_site(ord=ord, items=items, site=site) %}
<ul class="posts-list">
{% for post in post_list %}
<li><a href="{{ url_for(path=post.slug ~ '.html') }}">{{ post.title }}</a> - {{ post.date | date(format="%Y-%m-%d") }}</li>
Expand Down
6 changes: 3 additions & 3 deletions example/shortcodes/tags.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{# Display a list of all tags with post counts. Params: ord=desc, items=0 #}
{% macro tags(ord="desc", items=0) %}
{% set tag_map = group(kind="tag", ord=ord, items=items) %}
{# Display a list of all tags with post counts. Params: ord=desc, items=0, site=current #}
{% macro tags(ord="desc", items=0, site="") %}
{% set tag_map = group_from_site(kind="tag", ord=ord, items=items, site=site) %}
<ul class="tags-list">
{% for tag_name, posts in tag_map %}
{% set tag_slug = tag_name | slugify %}
Expand Down
87 changes: 86 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use log::{error, info};
use serde::{Deserialize, Serialize};
use serde_yaml::Value;
use std::{collections::HashMap, path::Path, process, sync::Arc};
use std::{collections::HashMap, fs, path::Path, process, sync::Arc};

use crate::cli::Cli;

Expand Down Expand Up @@ -268,6 +268,9 @@ pub struct Marmite {

#[serde(default = "default_gallery_thumb_size")]
pub gallery_thumb_size: u32,

#[serde(default)]
pub fragments_fallback: Option<String>,
}

fn default_true() -> bool {
Expand Down Expand Up @@ -419,6 +422,88 @@ impl Marmite {
self.publish_urls_json = publish_urls_json;
}
}

/// Create a fully configured subsite config by merging with this parent config
pub fn with_subsite_config(
&self,
subsite_config_path: &Path,
subsite_name: &str,
subsite_path: &Path,
) -> Self {
// Read and parse subsite configuration
let subsite_config_str = match fs::read_to_string(subsite_config_path) {
Ok(content) => content,
Err(e) => {
error!(
"Failed to read subsite config {}: {e}",
subsite_config_path.display()
);
return self.clone();
}
};

// Read subsite config from file and merge with parent config (file takes precedence over parent config)
let mut subsite_config = self.merged_from_subsite_config_file(&subsite_config_str);

// Set the site_path to include the subsite name
if self.site_path.is_empty() {
subsite_config.site_path = subsite_name.to_string();
} else {
subsite_config.site_path = format!("{}/{}", self.site_path, subsite_name);
}

// Update URL to include the subsite path
if !subsite_config.url.is_empty() && !subsite_config.url.ends_with('/') {
subsite_config.url.push('/');
}
subsite_config.url.push_str(&subsite_config.site_path);

// Set content_path to the actual subsite directory
subsite_config.content_path = subsite_path.to_string_lossy().to_string();

subsite_config
}

pub fn merged_from_subsite_config_file(&self, subsite_config_str: &str) -> Self {
// Try to parse once into a generic Value
let parsed_value: Value = match serde_yaml::from_str(subsite_config_str) {
Ok(val) => val,
Err(e) => {
error!("Failed to parse subsite config: {e}");
return self.clone();
}
};
let mut merged_yaml = parsed_value.clone();

let subsite_keys = parsed_value
.as_mapping()
.map(|m| {
m.keys()
.filter_map(|k| k.as_str().map(std::string::ToString::to_string))
.collect::<std::collections::HashSet<String>>()
})
.unwrap_or_default();

let parent_yaml = serde_yaml::to_value(self).unwrap();

if let (Some(merged_obj), Some(parent_obj)) =
(merged_yaml.as_mapping_mut(), parent_yaml.as_mapping())
{
for (key, value) in parent_obj {
if !subsite_keys.contains(key.as_str().unwrap_or_default()) {
merged_obj.insert(key.clone(), value.clone());
}
}
}

match serde_yaml::from_value(merged_yaml) {
Ok(merged) => merged,
Err(e) => {
error!("Failed to deserialize merged config: {e}");
self.clone()
}
}
}
}

#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
Expand Down
3 changes: 2 additions & 1 deletion src/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,8 @@ pub fn get_authors(frontmatter: &Frontmatter, default_author: Option<String>) ->
}
}
}
authors
// Ensure each author is slugified
authors.iter().map(|a| slugify(a)).collect()
}

/// Tries to get `date` from the front-matter metadata, else from filename
Expand Down
Loading