Color mode

Configuration

Create configuration with defineConfig and let the integration resolve it before content or plugins run. The required top-level field is site; optional sections are content, theme, plugins, and cache.

ts
import { defineConfig } from "@riebeckite/core";
 
export default defineConfig({
  site: { title: "My site", baseUrl: "https://example.com" },
  content: {
    directory: "content",
    exclude: ["drafts/**"],
    filters: { publishStrategy: "explicit" },
  },
  theme: { colorMode: "system", articleLayout: "article" },
  plugins: [],
});

Navigation is provided by the @riebeckite/plugin-navigation plugin, not by a top-level config section. The plugin produces a semantic model { primary, secondary } and owns the rendering mechanics through SiteNav; the Site decides where each rendered list is placed. primary and secondary express prominence, not placement; there is no header or footer key.

ts
import { defineConfig } from "@riebeckite/core";
import { navigation } from "@riebeckite/plugin-navigation";
 
export default defineConfig({
  site: { title: "My site", baseUrl: "https://example.com" },
  plugins: [navigation()],
});

Rendering

SiteNav from @riebeckite/plugin-navigation renders a resolved tree with the standard rb-nav structure, active-path detection, locale-aware normalization, and the aria-current contract. The Site decides where the tree is placed:

tsx
import { SiteNav } from "@riebeckite/plugin-navigation";
 
<SiteNav
  items={model.primary}
  path={c.req.path}
  language={c.get("htmlLanguage")}
/>;

Pass localizeHref to rewrite hrefs (for example to localize docs links), label to change the landmark label, and class/className to extend the <nav> classes.

Zero-config derivation

Called with no arguments, the plugin derives primary links from the vault's discoverable entries (manifest.discoverableEntries: public and discoverable, excluding draft and non-routable content). It reuses existing Riebeckite information rather than a dedicated vault file:

  • folder structure (a folder becomes a section, and nested folders become children)
  • README / index resolution (an index or README note represents its folder and supplies the folder href)
  • a folder without a README or index renders as a label with no link
  • a root README or index never appears in the navigation
  • page title (falling back to a formatted slug segment)
  • permalink

It requires no Riebeckite-specific vault file (no navigation.md) and no required frontmatter.

Derivation follows the language being rendered. Using the language metadata written by the l10n plugin, entries that share a translation collapse into a single item and only the current language's href is used, so a page under /ja/guide/ never mixes in /en/.... Vaults that do not use l10n keep deriving from every entry.

Pass items to replace the derived primary links. Pass secondary for supplementary links the Site renders less prominently.

ts
navigation({
  items: [
    { label: "Guide", href: "/guide" },
    {
      label: "Notes",
      href: "/notes/planning",
      children: [
        { label: "Planning", href: "/notes/planning" },
        { label: "Writing", href: "/notes/writing" },
      ],
    },
  ],
  secondary: [
    { label: "GitHub", href: "https://github.com/example/site", external: true },
  ],
});

Each link is a NavigationItem.

Field Type Meaning
label string Text shown for the link
href string Destination path or URL
children NavigationItem[] Child items, shown as a submenu
external boolean When true, opens in a new tab

label and href are required on authored items. A derived folder group that has no index note renders as a label only, so href is optional in the derived model.

Placement

The plugin does not decide placement; the Site shell does. In the reference Site, primary appears in the header and secondary in the footer. Some shells skip a href: "/" item because the site title already links home.

Building submenus

Use children to nest navigation.

ts
{
  label: "Notes",
  href: "/notes/planning",
  children: [
    { label: "Planning", href: "/notes/planning" },
    { label: "Writing", href: "/notes/writing" },
  ],
}

children render as a submenu.

Linking to external sites

Add external: true for links to other sites.

ts
{
  label: "GitHub",
  href: "https://github.com/example/site",
  external: true,
}

The link opens in a new tab and gets rel="noreferrer".

Marking the current page

The link for the page currently being viewed becomes active automatically.

For example, with:

ts
{ label: "Guide", href: "/guide" }

the item is active on pages such as:

text
/guide
/guide/getting-started
/en/guide
/en/guide/getting-started

Trailing slashes and a leading locale are normalized during matching, so you do not need to worry about differences such as /guide/ and /en/guide.

An active link gets aria-current="page".

An href that does not start with / (such as an external URL) and items with external: true are never treated as active.

On mobile

On narrow viewports, the site navigation collapses into a Menu disclosure.

The content and HTML structure do not change; only the CSS presentation changes with viewport width.

Validation

navigation options are validated while the plugin is loaded.

The main rules are:

  • label is a non-empty string
  • href is a non-empty string
  • external, when present, is a boolean
  • children must not contain an ancestor item

Invalid configuration fails while the plugin is loaded.

Plugin pages are not added automatically

The plugin derives links from the vault's discoverable entries. It does not surface plugin-generated pages on its own.

For example, the following are not added automatically:

  • Search
  • Tag / Folder indexes
  • Taxonomy pages
  • Plugin page types
  • Breadcrumbs
  • Backlinks
  • Related Posts
  • Other content graph features

To show a page a plugin provides, add a link to that page in navigation({ items }).

For the division of responsibility between navigation and plugins, see Customizing your site.

Exported helpers and types

@riebeckite/plugin-navigation exports navigation, buildNavigation, resolveSiteNavigation, NAVIGATION_PLUGIN_NAME, the SiteNav rendering primitive, and the types NavigationItem, NavigationOptions, SiteNavigation, and SiteNavProps.

Content selection

content.directory selects the default filesystem location. Use content.source to provide a different ContentSource; do not configure two competing readers. exclude removes matching material before it becomes content. filters.publishStrategy controls the default publishing policy, and frontmatter can override the resolved publishing state.

Excluding files

exclude takes glob patterns matched against logical paths. * matches within one path segment, ** matches across segments, and ? matches one character.

ts
content: {
  directory: "content",
  exclude: ["drafts/**", "**/private/**", ".obsidian/**"],
}

Excluded files never enter the content pipeline, so they are unavailable for links, graph analysis, and diagnostics. This differs from draft and unlisted, which stay in the content system but are hidden from routes or discovery. exclude runs before publishing is resolved:

Diagram source
text
flowchart LR
    Files["Files"]
    Exclude{"exclude ?"}
    Content["Content"]
    Publish{"Published ?"}
    Public["Public Content"]
    Private["Not Published"]
 
    Files --> Exclude
    Exclude -->|Yes| Skip["Not loaded"]
    Exclude -->|No| Content
    Content --> Publish
    Publish -->|Yes| Public
    Publish -->|No| Private

ContentSource

content.directory selects the default filesystem reader. Set content.source to replace that reader with another implementation, such as a remote store:

text
content.directory  →  default filesystem ContentSource
 
content.source     →  custom ContentSource

Use one or the other. content.source is not a second reader for the same content; it stands in for the filesystem reader. The ContentSource contract is listed in the Configuration reference.

Publishing state

Publishing is resolved once in Core and exposed to plugins as two manifest views:

View Contains Use for
manifest.publicEntries Routable entries: public and unlisted page rendering and SSG paths
manifest.discoverableEntries Public entries only docs navigation, search, feeds, sitemap, taxonomy, graphs, backlinks, related/recent lists

The default publishStrategy still applies when no explicit visibility is set.

Frontmatter Result
visibility: public routable and discoverable
visibility: unlisted routable by direct URL, but excluded from discovery surfaces
visibility: draft not routable and not discoverable
publishAt: 2026-01-01T00:00:00.000Z hidden before the build time, public on the first build after that time
no visibility / publishAt falls back to publishStrategy (explicit requires publish: true; selective excludes private: true and draft: true)

Malformed visibility or publishAt values fail the build instead of being guessed. Scheduled publishing is build-time only: Riebeckite does not start a runtime timer.

exclude is different from publishing. Excluded files never enter the content pipeline, so they are unavailable for links, metadata, graph analysis, and diagnostics. Draft, unlisted, and scheduled-before entries remain in the raw manifest for internal processing, but Core keeps them out of the route or discovery views according to the table above.

Filesystem roots and external vaults

Riebeckite keeps the site application and its source material separate. The following names describe different directories and must not be used interchangeably:

Name Responsibility Default / resolution base
appRoot The HonoX/Vite application: app/, public/, routes, generated styles, and build output configuration Vite's root
configRoot Directory containing riebeckite.config.ts, .js, or .mjs appRoot
contentRoot Absolute filesystem root for the configured content directory or Obsidian vault path.resolve(appRoot, content.directory)

configRoot determines where the config module is imported from. It does not change the base for a relative content.directory: that base is always appRoot. The integration resolves all three roots before running content or plugins, and CLI commands reuse that result. Consequently, execution from a nested directory, CI working directory, or editor task does not change which vault is read.

Keep an Obsidian vault outside the site when it is used independently by Obsidian, shared by multiple site applications, or stored in another Git repository:

text
workspace/
├─ site/
│  ├─ package.json
│  ├─ vite.config.ts
│  ├─ riebeckite.config.ts
│  ├─ app/
│  └─ public/
└─ vault/
   ├─ index.md
   ├─ notes/
   ├─ attachments/
   └─ media/

With this layout, the site configuration is explicit and portable:

ts
// site/riebeckite.config.ts
import { defineConfig } from "@riebeckite/core";
import { attachment } from "@riebeckite/plugin-attachment";
import { media } from "@riebeckite/plugin-media";
import { obsidianMarkdown } from "@riebeckite/plugin-obsidian-markdown";
 
export default defineConfig({
  site: { title: "My notes" },
  content: {
    directory: "../vault",
    exclude: [".obsidian/**", "Templates/**"],
  },
  plugins: [obsidianMarkdown(), media(), attachment()],
});

An absolute directory is valid, but a relative path is normally easier to move between developer machines and CI. Do not derive the value with process.cwd(), and do not make appRoot point to the vault. The vault is source data; Vite's application root must remain the site.

Application-side content access

The HonoX integration resolves the content root automatically and exposes the resolved values as framework-owned modules. A site imports them instead of re-reading riebeckite.config.ts:

ts
import { config } from "virtual:riebeckite/config";
import { content } from "virtual:riebeckite/content";

config.content.directory is already absolute, and content is a ready ContentManager bound to it, so there is no second resolution step and no per-site config copy to keep in sync. The riebeckiteVite() plugin resolves these modules. A script that runs outside Vite (for example a Node script started with tsx) can resolve the same values with resolveHonoxConfig from @riebeckite/honox/runtime:

ts
import { fileURLToPath } from "node:url";
import { resolveConfigModule } from "@riebeckite/core";
import { resolveHonoxConfig } from "@riebeckite/honox/runtime";
import * as rawConfigModule from "../riebeckite.config";
 
const appRoot = fileURLToPath(new URL("../", import.meta.url));
export const config = resolveHonoxConfig(resolveConfigModule(rawConfigModule), appRoot);

If content.source is configured, it replaces the filesystem reader; do not use it as a second reader for the same vault.

Attachments and media

An attachment or media file is a vault file that is neither Markdown nor an image. Images in the vault are handled separately as content images, so keep the two apart.

obsidianMarkdown() gives vault files logical paths relative to contentRoot. For example, ![[attachments/report.pdf]] is rendered by attachment() and ![[media/interview.mp3]] by media(). Their generated URLs use this stable public shape:

text
/assets/attachments/<logical-path-relative-to-the-vault>

attachment() reads the embedded file size from the resolved vault root and rejects paths outside it. media() renders supported audio and video formats using the same logical path.

Asset URLs and published files

Generating a URL and publishing the file are two separate things. Assets fall into three kinds, each published by a different owner:

Kind Subject Public URL Published by
Content image An image in the vault /<logical-path-relative-to-the-vault> The Riebeckite build
Attachment / Media A file that is neither Markdown nor an image /assets/attachments/<logical-path-relative-to-the-vault> The site application
Static asset A file the site application owns anywhere under / Vite's public/ directory
Diagram source
text
flowchart LR
    Vault["Vault"]
    Image["Content image"]
    Attach["Attachment / Media"]
 
    Vault --> Image
    Vault --> Attach
 
    Image -->|"written by the build"| Output["Build output"]
    Attach -->|"URL only"| Public["public/"]
    Public --> Output

Content images are published by the build

obsidianMarkdown() writes every image referenced from a public page as build output. The image reaches the build output and is reachable through the generated URL without any action from the site application, keeping its logical path intact:

text
assets/logo.png
 
↓
 
/assets/logo.png

During development the same logical path is served directly from the content source. Images that nothing references, and images referenced only from non-public pages, are not written.

Publishing attachments and media is the site's responsibility

For attachments and media, generating a URL does not copy the binary file into the Vite public directory. The site application must copy only the assets it intends to publish to public/assets/attachments/, preserving their logical vault-relative paths. The reference application's build_images.ts shows an incremental, referenced-attachment-only implementation.

Static assets in public/

public/ is where the site application keeps its own assets. Everything below it is copied into the build output as-is. Keep it for site-owned files instead of dumping every vault image or attachment there, so the published set stays narrow.

Vaults are not published wholesale

Do not copy the whole vault as a shortcut:

text
vault/**
   ↓
public/**

That can expose private notes, images referenced only from non-public pages, unreferenced attachments, and .obsidian metadata. The build emits images referenced by published content. Attachment cards and audio/video embeds render URLs under /assets/attachments/<logical path>, and the site application must copy those files into public/assets/attachments/ before build if you want them served after deployment. See Separate Content Repository for the detailed data flow.

Verification and troubleshooting

Run the CLI from a nested application directory to prove that configuration is not tied to the current working directory:

sh
cd site/app
npm exec riebeckite check
npm exec riebeckite doctor
npm exec riebeckite inspect config
npm exec -- riebeckite inspect content --list
npm exec riebeckite build

Use the results in this order:

  1. check validates the config and plugin contracts.
  2. doctor reports an unreadable or invalid filesystem content source.
  3. inspect config confirms the resolved directory.
  4. inspect content --list confirms the expected logical paths before you diagnose a WikiLink or embed.
  5. build verifies the integration and route rendering.

If riebeckite.config.ts intentionally lives outside the Vite application, pass configRoot to riebeckiteVite(). Keep appRoot set to the site root and keep relative content.directory values relative to that root.

Build cache

cache controls the persistent cache used during builds. It is optional; the integration supplies a suitable directory by default.

Field Default Meaning
cache.enabled true Set to false to disable the persistent cache and rebuild everything.
cache.directory <buildDirectory>/cache Overrides where cache entries are stored. Set it to relocate or share the cache; an unset value keeps the integration default.

The cache stores processed content and plugin results between builds. Disabling it or moving its directory only affects build performance, not the output; a cold build produces the same result.

Plugins and themes

Plugins accept plugin inputs, including false, null, and undefined for conditional configuration. Resolution discards disabled/falsy inputs, orders enabled plugins stably, and checks capabilities. Theme input can be a raw theme config or a declared theme. Keep framework-specific configuration at the integration/application boundary.

Configuration errors are reported as ConfigValidationError; do not catch and hide them. Run riebeckite check after changes. Continue with Plugin system or Theme system for their option contracts.

Summary

Configuration keeps the site and its content separate:

text
appRoot     = where the site application lives
 
configRoot  = where riebeckite.config.* lives
 
contentRoot = where the Markdown or vault lives

Relative content.directory values resolve against appRoot, never configRoot or process.cwd(). Most sites never need to think about the three boundaries. They matter only when you use an external vault, keep content in a separate repository, or work in an unusual monorepo layout.

History

1 changesCollapseExpand
1 + # Configuration
2 +
3 + Create configuration with `defineConfig` and let the integration resolve it before content or plugins run. The required top-level field is `site`; optional sections are `content`, `theme`, `plugins`, and `cache`.
4 +
5 + ```ts
6 + import { defineConfig } from "@riebeckite/core";
7 +
8 + export default defineConfig({
9 + site: { title: "My site", baseUrl: "https://example.com" },
10 + content: {
11 + directory: "content",
12 + exclude: ["drafts/**"],
13 + filters: { publishStrategy: "explicit" },
14 + },
15 + theme: { colorMode: "system", articleLayout: "article" },
16 + plugins: [],
17 + });
18 + ```
19 +
20 + ## Navigation
21 +
22 + Navigation is provided by the **`@riebeckite/plugin-navigation`** plugin, not by a top-level config section. The plugin produces a semantic model `{ primary, secondary }` and owns the rendering mechanics through `SiteNav`; the **Site decides where each rendered list is placed**. `primary` and `secondary` express prominence, not placement; there is no `header` or `footer` key.
23 +
24 + ```ts
25 + import { defineConfig } from "@riebeckite/core";
26 + import { navigation } from "@riebeckite/plugin-navigation";
27 +
28 + export default defineConfig({
29 + site: { title: "My site", baseUrl: "https://example.com" },
30 + plugins: [navigation()],
31 + });
32 + ```
33 +
34 + ### Rendering
35 +
36 + `SiteNav` from `@riebeckite/plugin-navigation` renders a resolved tree with the standard `rb-nav` structure, active-path detection, locale-aware normalization, and the `aria-current` contract. The Site decides where the tree is placed:
37 +
38 + ```tsx
39 + import { SiteNav } from "@riebeckite/plugin-navigation";
40 +
41 + <SiteNav
42 + items={model.primary}
43 + path={c.req.path}
44 + language={c.get("htmlLanguage")}
45 + />;
46 + ```
47 +
48 + Pass `localizeHref` to rewrite hrefs (for example to localize docs links), `label` to change the landmark label, and `class`/`className` to extend the `<nav>` classes.
49 +
50 + ### Zero-config derivation
51 +
52 + Called with no arguments, the plugin derives `primary` links from the vault's **discoverable entries** (`manifest.discoverableEntries`: public and discoverable, excluding draft and non-routable content). It reuses existing Riebeckite information rather than a dedicated vault file:
53 +
54 + - folder structure (a folder becomes a section, and nested folders become `children`)
55 + - README / index resolution (an `index` or `README` note represents its folder and supplies the folder `href`)
56 + - a folder without a README or index renders as a label with no link
57 + - a root README or index never appears in the navigation
58 + - page `title` (falling back to a formatted slug segment)
59 + - `permalink`
60 +
61 + It requires **no Riebeckite-specific vault file** (no `navigation.md`) and **no required frontmatter**.
62 +
63 + Derivation follows the language being rendered. Using the language metadata written by the `l10n` plugin, entries that share a translation collapse into a single item and only the current language's `href` is used, so a page under `/ja/guide/` never mixes in `/en/...`. Vaults that do not use `l10n` keep deriving from every entry.
64 +
65 + ### Manual and supplementary links
66 +
67 + Pass `items` to replace the derived `primary` links. Pass `secondary` for supplementary links the Site renders less prominently.
68 +
69 + ```ts
70 + navigation({
71 + items: [
72 + { label: "Guide", href: "/guide" },
73 + {
74 + label: "Notes",
75 + href: "/notes/planning",
76 + children: [
77 + { label: "Planning", href: "/notes/planning" },
78 + { label: "Writing", href: "/notes/writing" },
79 + ],
80 + },
81 + ],
82 + secondary: [
83 + { label: "GitHub", href: "https://github.com/example/site", external: true },
84 + ],
85 + });
86 + ```
87 +
88 + ### NavigationItem
89 +
90 + Each link is a `NavigationItem`.
91 +
92 + | Field | Type | Meaning |
93 + | --- | --- | --- |
94 + | `label` | `string` | Text shown for the link |
95 + | `href` | `string` | Destination path or URL |
96 + | `children` | `NavigationItem[]` | Child items, shown as a submenu |
97 + | `external` | `boolean` | When `true`, opens in a new tab |
98 +
99 + `label` and `href` are required on authored items. A derived folder group that has no index note renders as a label only, so `href` is optional in the derived model.
100 +
101 + ### Placement
102 +
103 + The plugin does not decide placement; the Site shell does. In the reference Site, `primary` appears in the header and `secondary` in the footer. Some shells skip a `href: "/"` item because the site title already links home.
104 +
105 + ### Building submenus
106 +
107 + Use `children` to nest navigation.
108 +
109 + ```ts
110 + {
111 + label: "Notes",
112 + href: "/notes/planning",
113 + children: [
114 + { label: "Planning", href: "/notes/planning" },
115 + { label: "Writing", href: "/notes/writing" },
116 + ],
117 + }
118 + ```
119 +
120 + `children` render as a submenu.
121 +
122 + ### Linking to external sites
123 +
124 + Add `external: true` for links to other sites.
125 +
126 + ```ts
127 + {
128 + label: "GitHub",
129 + href: "https://github.com/example/site",
130 + external: true,
131 + }
132 + ```
133 +
134 + The link opens in a new tab and gets `rel="noreferrer"`.
135 +
136 + ### Marking the current page
137 +
138 + The link for the page currently being viewed becomes active automatically.
139 +
140 + For example, with:
141 +
142 + ```ts
143 + { label: "Guide", href: "/guide" }
144 + ```
145 +
146 + the item is active on pages such as:
147 +
148 + ```text
149 + /guide
150 + /guide/getting-started
151 + /en/guide
152 + /en/guide/getting-started
153 + ```
154 +
155 + Trailing slashes and a leading locale are normalized during matching, so you do not need to worry about differences such as `/guide/` and `/en/guide`.
156 +
157 + An active link gets `aria-current="page"`.
158 +
159 + An `href` that does not start with `/` (such as an external URL) and items with `external: true` are never treated as active.
160 +
161 + ### On mobile
162 +
163 + On narrow viewports, the site navigation collapses into a `Menu` disclosure.
164 +
165 + The content and HTML structure do not change; only the CSS presentation changes with viewport width.
166 +
167 + ### Validation
168 +
169 + `navigation` options are validated while the plugin is loaded.
170 +
171 + The main rules are:
172 +
173 + - `label` is a non-empty string
174 + - `href` is a non-empty string
175 + - `external`, when present, is a boolean
176 + - `children` must not contain an ancestor item
177 +
178 + Invalid configuration fails while the plugin is loaded.
179 +
180 + ### Plugin pages are not added automatically
181 +
182 + The plugin derives links from the vault's discoverable entries. It does not surface plugin-generated pages on its own.
183 +
184 + For example, the following are not added automatically:
185 +
186 + - Search
187 + - Tag / Folder indexes
188 + - Taxonomy pages
189 + - Plugin page types
190 + - Breadcrumbs
191 + - Backlinks
192 + - Related Posts
193 + - Other content graph features
194 +
195 + To show a page a plugin provides, add a link to that page in `navigation({ items })`.
196 +
197 + For the division of responsibility between navigation and plugins, see [Customizing your site](../guides/customizing-your-site.en.md#navigation).
198 +
199 + ### Exported helpers and types
200 +
201 + `@riebeckite/plugin-navigation` exports `navigation`, `buildNavigation`, `resolveSiteNavigation`, `NAVIGATION_PLUGIN_NAME`, the `SiteNav` rendering primitive, and the types `NavigationItem`, `NavigationOptions`, `SiteNavigation`, and `SiteNavProps`.
202 +
203 + ## Content selection
204 +
205 + `content.directory` selects the default filesystem location. Use `content.source` to provide a different `ContentSource`; do not configure two competing readers. `exclude` removes matching material before it becomes content. `filters.publishStrategy` controls the default publishing policy, and frontmatter can override the resolved publishing state.
206 +
207 + ### Excluding files
208 +
209 + `exclude` takes glob patterns matched against logical paths. `*` matches within
210 + one path segment, `**` matches across segments, and `?` matches one character.
211 +
212 + ```ts
213 + content: {
214 + directory: "content",
215 + exclude: ["drafts/**", "**/private/**", ".obsidian/**"],
216 + }
217 + ```
218 +
219 + Excluded files never enter the content pipeline, so they are unavailable for
220 + links, graph analysis, and diagnostics. This differs from `draft` and `unlisted`,
221 + which stay in the content system but are hidden from routes or discovery.
222 + `exclude` runs before publishing is resolved:
223 +
224 + ```mermaid
225 + flowchart LR
226 + Files["Files"]
227 + Exclude{"exclude ?"}
228 + Content["Content"]
229 + Publish{"Published ?"}
230 + Public["Public Content"]
231 + Private["Not Published"]
232 +
233 + Files --> Exclude
234 + Exclude -->|Yes| Skip["Not loaded"]
235 + Exclude -->|No| Content
236 + Content --> Publish
237 + Publish -->|Yes| Public
238 + Publish -->|No| Private
239 + ```
240 +
241 + ### ContentSource
242 +
243 + `content.directory` selects the default filesystem reader. Set `content.source`
244 + to replace that reader with another implementation, such as a remote store:
245 +
246 + ```text
247 + content.directory → default filesystem ContentSource
248 +
249 + content.source → custom ContentSource
250 + ```
251 +
252 + Use one or the other. `content.source` is not a second reader for the same
253 + content; it stands in for the filesystem reader. The `ContentSource` contract is
254 + listed in the [Configuration reference](./configuration-reference.en.md#contentsource).
255 +
256 + ### Publishing state
257 +
258 + Publishing is resolved once in Core and exposed to plugins as two manifest views:
259 +
260 + | View | Contains | Use for |
261 + | --- | --- | --- |
262 + | `manifest.publicEntries` | Routable entries: public and unlisted | page rendering and SSG paths |
263 + | `manifest.discoverableEntries` | Public entries only | docs navigation, search, feeds, sitemap, taxonomy, graphs, backlinks, related/recent lists |
264 +
265 + The default `publishStrategy` still applies when no explicit visibility is set.
266 +
267 + | Frontmatter | Result |
268 + | --- | --- |
269 + | `visibility: public` | routable and discoverable |
270 + | `visibility: unlisted` | routable by direct URL, but excluded from discovery surfaces |
271 + | `visibility: draft` | not routable and not discoverable |
272 + | `publishAt: 2026-01-01T00:00:00.000Z` | hidden before the build time, public on the first build after that time |
273 + | no `visibility` / `publishAt` | falls back to `publishStrategy` (`explicit` requires `publish: true`; `selective` excludes `private: true` and `draft: true`) |
274 +
275 + Malformed `visibility` or `publishAt` values fail the build instead of being guessed. Scheduled publishing is build-time only: Riebeckite does not start a runtime timer.
276 +
277 + `exclude` is different from publishing. Excluded files never enter the content pipeline, so they are unavailable for links, metadata, graph analysis, and diagnostics. Draft, unlisted, and scheduled-before entries remain in the raw manifest for internal processing, but Core keeps them out of the route or discovery views according to the table above.
278 +
279 + ## Filesystem roots and external vaults
280 +
281 + Riebeckite keeps the site application and its source material separate. The
282 + following names describe different directories and must not be used
283 + interchangeably:
284 +
285 + | Name | Responsibility | Default / resolution base |
286 + | --- | --- | --- |
287 + | `appRoot` | The HonoX/Vite application: `app/`, `public/`, routes, generated styles, and build output configuration | Vite's `root` |
288 + | `configRoot` | Directory containing `riebeckite.config.ts`, `.js`, or `.mjs` | `appRoot` |
289 + | `contentRoot` | Absolute filesystem root for the configured content directory or Obsidian vault | `path.resolve(appRoot, content.directory)` |
290 +
291 + `configRoot` determines where the config module is imported from. It does
292 + **not** change the base for a relative `content.directory`: that base is always
293 + `appRoot`. The integration resolves all three roots before running content or
294 + plugins, and CLI commands reuse that result. Consequently, execution from a
295 + nested directory, CI working directory, or editor task does not change which
296 + vault is read.
297 +
298 + ### Recommended layout
299 +
300 + Keep an Obsidian vault outside the site when it is used independently by
301 + Obsidian, shared by multiple site applications, or stored in another Git
302 + repository:
303 +
304 + ```text
305 + workspace/
306 + ├─ site/
307 + │ ├─ package.json
308 + │ ├─ vite.config.ts
309 + │ ├─ riebeckite.config.ts
310 + │ ├─ app/
311 + │ └─ public/
312 + └─ vault/
313 + ├─ index.md
314 + ├─ notes/
315 + ├─ attachments/
316 + └─ media/
317 + ```
318 +
319 + With this layout, the site configuration is explicit and portable:
320 +
321 + ```ts
322 + // site/riebeckite.config.ts
323 + import { defineConfig } from "@riebeckite/core";
324 + import { attachment } from "@riebeckite/plugin-attachment";
325 + import { media } from "@riebeckite/plugin-media";
326 + import { obsidianMarkdown } from "@riebeckite/plugin-obsidian-markdown";
327 +
328 + export default defineConfig({
329 + site: { title: "My notes" },
330 + content: {
331 + directory: "../vault",
332 + exclude: [".obsidian/**", "Templates/**"],
333 + },
334 + plugins: [obsidianMarkdown(), media(), attachment()],
335 + });
336 + ```
337 +
338 + An absolute `directory` is valid, but a relative path is normally easier to
339 + move between developer machines and CI. Do not derive the value with
340 + `process.cwd()`, and do not make `appRoot` point to the vault. The vault is
341 + source data; Vite's application root must remain the site.
342 +
343 + ### Application-side content access
344 +
345 + The HonoX integration resolves the content root automatically and exposes the
346 + resolved values as framework-owned modules. A site imports them instead of
347 + re-reading `riebeckite.config.ts`:
348 +
349 + ```ts
350 + import { config } from "virtual:riebeckite/config";
351 + import { content } from "virtual:riebeckite/content";
352 + ```
353 +
354 + `config.content.directory` is already absolute, and `content` is a ready
355 + `ContentManager` bound to it, so there is no second resolution step and no
356 + per-site config copy to keep in sync. The `riebeckiteVite()` plugin resolves
357 + these modules. A script that runs outside Vite (for example a Node script
358 + started with `tsx`) can resolve the same values with `resolveHonoxConfig` from
359 + `@riebeckite/honox/runtime`:
360 +
361 + ```ts
362 + import { fileURLToPath } from "node:url";
363 + import { resolveConfigModule } from "@riebeckite/core";
364 + import { resolveHonoxConfig } from "@riebeckite/honox/runtime";
365 + import * as rawConfigModule from "../riebeckite.config";
366 +
367 + const appRoot = fileURLToPath(new URL("../", import.meta.url));
368 + export const config = resolveHonoxConfig(resolveConfigModule(rawConfigModule), appRoot);
369 + ```
370 +
371 + If `content.source` is configured, it replaces the filesystem reader; do not use
372 + it as a second reader for the same vault.
373 +
374 + ### Attachments and media
375 +
376 + An attachment or media file is a vault file that is **neither Markdown nor an
377 + image**. Images in the vault are handled separately as content images, so keep
378 + the two apart.
379 +
380 + `obsidianMarkdown()` gives vault files logical paths relative to
381 + `contentRoot`. For example, `![[attachments/report.pdf]]` is rendered by
382 + `attachment()` and `![[media/interview.mp3]]` by `media()`. Their generated
383 + URLs use this stable public shape:
384 +
385 + ```text
386 + /assets/attachments/<logical-path-relative-to-the-vault>
387 + ```
388 +
389 + `attachment()` reads the embedded file size from the resolved vault root and
390 + rejects paths outside it. `media()` renders supported audio and video formats
391 + using the same logical path.
392 +
393 + ### Asset URLs and published files
394 +
395 + Generating a URL and publishing the file are two separate things. Assets fall
396 + into three kinds, each published by a different owner:
397 +
398 + |Kind|Subject|Public URL|Published by|
399 + | --- | --- | --- | --- |
400 + |Content image|An image in the vault|`/<logical-path-relative-to-the-vault>`|The Riebeckite build|
401 + |Attachment / Media|A file that is neither Markdown nor an image|`/assets/attachments/<logical-path-relative-to-the-vault>`|The site application|
402 + |Static asset|A file the site application owns|anywhere under `/`|Vite's `public/` directory|
403 +
404 + ```mermaid
405 + flowchart LR
406 + Vault["Vault"]
407 + Image["Content image"]
408 + Attach["Attachment / Media"]
409 +
410 + Vault --> Image
411 + Vault --> Attach
412 +
413 + Image -->|"written by the build"| Output["Build output"]
414 + Attach -->|"URL only"| Public["public/"]
415 + Public --> Output
416 + ```
417 +
418 + #### Content images are published by the build
419 +
420 + `obsidianMarkdown()` writes every image referenced from a public page as build
421 + output. The image reaches the build output and is reachable through the
422 + generated URL without any action from the site application, keeping its logical
423 + path intact:
424 +
425 + ```text
426 + assets/logo.png
427 +
428 + ↓
429 +
430 + /assets/logo.png
431 + ```
432 +
433 + During development the same logical path is served directly from the content
434 + source. Images that nothing references, and images referenced only from
435 + non-public pages, are not written.
436 +
437 + #### Publishing attachments and media is the site's responsibility
438 +
439 + For attachments and media, generating a URL does **not** copy the binary file
440 + into the Vite public directory. The site application must copy only the assets
441 + it intends to publish to `public/assets/attachments/`, preserving their logical
442 + vault-relative paths. The reference application's
443 + `build_images.ts` shows an
444 + incremental, referenced-attachment-only implementation.
445 +
446 + #### Static assets in `public/`
447 +
448 + `public/` is where the site application keeps its own assets. Everything below
449 + it is copied into the build output as-is. Keep it for site-owned files instead
450 + of dumping every vault image or attachment there, so the published set stays
451 + narrow.
452 +
453 + ### Vaults are not published wholesale
454 +
455 + Do not copy the whole vault as a shortcut:
456 +
457 + ```text
458 + vault/**
459 + ↓
460 + public/**
461 + ```
462 +
463 + That can expose private notes, images referenced only from non-public pages,
464 + unreferenced attachments, and `.obsidian` metadata. The build emits images
465 + referenced by published content. Attachment cards and audio/video embeds render
466 + URLs under `/assets/attachments/<logical path>`, and the site application must
467 + copy those files into `public/assets/attachments/` before build if you want them
468 + served after deployment. See [Separate Content Repository](../guides/deployment/separate-content-repository.en.md#4-3-content-images-and-attachments-are-published-differently) for the detailed data flow.
469 +
470 + ### Verification and troubleshooting
471 +
472 + Run the CLI from a nested application directory to prove that configuration is
473 + not tied to the current working directory:
474 +
475 + ```sh
476 + cd site/app
477 + npm exec riebeckite check
478 + npm exec riebeckite doctor
479 + npm exec riebeckite inspect config
480 + npm exec -- riebeckite inspect content --list
481 + npm exec riebeckite build
482 + ```
483 +
484 + Use the results in this order:
485 +
486 + 1. `check` validates the config and plugin contracts.
487 + 2. `doctor` reports an unreadable or invalid filesystem content source.
488 + 3. `inspect config` confirms the resolved directory.
489 + 4. `inspect content --list` confirms the expected logical paths before you
490 + diagnose a WikiLink or embed.
491 + 5. `build` verifies the integration and route rendering.
492 +
493 + If `riebeckite.config.ts` intentionally lives outside the Vite application,
494 + pass `configRoot` to `riebeckiteVite()`. Keep `appRoot` set to the site root and
495 + keep relative `content.directory` values relative to that root.
496 +
497 + ## Build cache
498 +
499 + `cache` controls the persistent cache used during builds. It is optional; the integration supplies a suitable directory by default.
500 +
501 + | Field | Default | Meaning |
502 + | --- | --- | --- |
503 + | `cache.enabled` | `true` | Set to `false` to disable the persistent cache and rebuild everything. |
504 + | `cache.directory` | `<buildDirectory>/cache` | Overrides where cache entries are stored. Set it to relocate or share the cache; an unset value keeps the integration default. |
505 +
506 + The cache stores processed content and plugin results between builds. Disabling it or moving its directory only affects build performance, not the output; a cold build produces the same result.
507 +
508 + ## Plugins and themes
509 +
510 + Plugins accept plugin inputs, including `false`, `null`, and `undefined` for conditional configuration. Resolution discards disabled/falsy inputs, orders enabled plugins stably, and checks capabilities. Theme input can be a raw theme config or a declared theme. Keep framework-specific configuration at the integration/application boundary.
511 +
512 + Configuration errors are reported as `ConfigValidationError`; do not catch and hide them. Run `riebeckite check` after changes. Continue with [Plugin system](plugin-api.en.md) or [Theme system](theme-api.en.md) for their option contracts.
513 +
514 + ## Summary
515 +
516 + Configuration keeps the site and its content separate:
517 +
518 + ```text
519 + appRoot = where the site application lives
520 +
521 + configRoot = where riebeckite.config.* lives
522 +
523 + contentRoot = where the Markdown or vault lives
524 + ```
525 +
526 + Relative `content.directory` values resolve against `appRoot`, never
527 + `configRoot` or `process.cwd()`. Most sites never need to think about the three
528 + boundaries. They matter only when you use an external vault, keep content in a
529 + separate repository, or work in an unusual monorepo layout.
530 +