Color mode

Permalink

Stable, configurable public URLs (permalinks) for Riebeckite content.

Instead of coupling public URLs directly to the filesystem layout, this plugin can build URLs from frontmatter IDs, deterministic path-derived IDs, or custom resolvers. Legacy URLs can also be registered as redirects in the same configuration.

日本語

Basic usage

ts
import { defineConfig } from "@riebeckite/core";
import { permalink } from "@riebeckite/plugin-permalink";
 
export default defineConfig({
  plugins: [
    permalink({
      frontmatter: "id",
      id: {
        strategy: "frontmatter-or-hash",
        length: 12,
      },
      path: {
        mode: "flat",
        prefix: "/n",
        trailingSlash: false,
      },
      redirects: {
        frontmatter: "redirect_from",
        status: 308,
      },
    }),
  ],
});

Given the following content:

md
---
id: hello-world
---
 
# Hello

with path.mode: "flat" and path.prefix: "/n", the public URL becomes:

text
/n/hello-world

With the default frontmatter-or-hash strategy, content without a frontmatter ID receives a deterministic ID derived from its file path.

This means an existing Obsidian vault does not need an id field added to every file.


How it works

permalink() uses the build-time resolveContentLocations extension point to resolve the public location of each content entry.

Conceptually:

text
Content
   │
   ├─ frontmatter
   ├─ slug
   └─ source path
          │
          ▼
@riebeckite/plugin-permalink
          │
          ├─ resolve ID
          ├─ resolve path
          ├─ normalize
          ├─ validate
          └─ redirects
          │
          ▼
ContentPublicLocation
          │
          ├─ permalink
          ├─ redirects
          └─ metadata

Core treats the resolved permalink as the canonical public URL.

The same canonical URL is then used by Wikilinks, backlinks, search, the content graph, SEO canonical URLs, sitemaps, RSS / Atom / JSON Feed, and other consumers.

Without this plugin, Riebeckite still resolves every URL through the Core default public location resolver, resolveDefaultContentLocation, which maps index to / and every other entry to /{slug}. That default is a first-class Core policy, not a fallback.


Frontmatter

ID

By default, the plugin reads the id field:

md
---
id: hello-world
---

The field name is configurable:

ts
permalink({
  frontmatter: "permalink-id",
});
md
---
permalink-id: hello-world
---

frontmatter refers to a top-level frontmatter field.

A specific entry can override its public URL entirely:

md
---
id: about-page
permalink: /about
---

The explicit permalink takes precedence over normal ID and path resolution.

The content ID and its public location remain separate concepts:

text
ID
about-page
 
Canonical URL
/about

The field name can also be changed:

ts
permalink({
  override: {
    frontmatter: "url",
  },
});

Redirects

Legacy URLs can be declared in frontmatter:

md
---
id: hello-world
redirect_from:
  - /posts/hello
  - /blog/2024/hello-world
---

If the canonical URL is:

text
/n/hello-world

the redirects resolve to it:

text
/posts/hello
        ↓ 308
/n/hello-world
 
/blog/2024/hello-world
        ↓ 308
/n/hello-world

Configure the field and status with:

ts
permalink({
  redirects: {
    frontmatter: "redirect_from",
    status: 308,
  },
});

redirect_from accepts either a string or an array of strings.


Options

Option Type Default Description
frontmatter string "id" Top-level frontmatter field used as the ID
id.strategy "frontmatter" | "hash" | "frontmatter-or-hash" "frontmatter-or-hash" How IDs are resolved
id.length number 12 Length of path-derived hash IDs (6–43)
path.mode "flat" | "preserve" | "append" "flat" How the resolved ID is placed into the public URL
path.prefix string "/n" URL prefix. "/" or an empty string means no prefix
path.trailingSlash boolean false Whether canonical URLs end with /
index.collapse boolean true Whether the index segment is collapsed
override.frontmatter string "permalink" Frontmatter field containing a complete permalink override
redirects.frontmatter string "redirect_from" Frontmatter field containing legacy URLs
redirects.status 301 | 302 | 307 | 308 308 Redirect HTTP status
resolveId (content) => string — Fully customize ID resolution
resolvePath ({ content, id }) => string — Fully customize public path generation

ID strategies

frontmatter

Uses the configured frontmatter field as the ID.

ts
permalink({
  frontmatter: "id",
  id: {
    strategy: "frontmatter",
  },
});

Every content entry must provide the field. A missing ID causes a build error.

This is useful when URL identity should be explicitly controlled:

md
---
id: article-123
---

With flat mode, renaming or moving the source file does not change the ID or public URL.


hash

Ignores the frontmatter ID and derives the ID from the source file path.

ts
permalink({
  id: {
    strategy: "hash",
    length: 12,
  },
});

The input path is normalized by:

  • replacing \ with /
  • stripping leading /
  • applying Unicode NFC normalization

The normalized path is hashed using SHA-256, encoded as base64url, and truncated to id.length.

text
notes/flutter/riverpod.md
        │
        ▼
normalized path
        │
        ▼
SHA-256
        │
        ▼
base64url
        │
        ▼
K7m3Qp8d...

The same path produces the same ID across operating systems and build environments.

Because the source path is the input, renaming or moving the file changes the ID.


frontmatter-or-hash

This is the default strategy.

ts
permalink({
  frontmatter: "id",
  id: {
    strategy: "frontmatter-or-hash",
  },
});

Resolution works as follows:

text
frontmatter ID exists
        ↓
use frontmatter ID
 
frontmatter ID missing
        ↓
derive ID from path

This is useful for existing Obsidian vaults: content works without modification, while important entries can opt into explicit stable IDs.


ID metadata

The source of a resolved ID is exposed through metadata.

metadata.idSource Meaning
frontmatter Taken from the configured frontmatter field
derived Derived from the source path hash
custom Returned by resolveId

A manual permalink override changes the URL without removing identity. When the frontmatter ID is present it is still recorded (metadata.idSource is frontmatter); metadata is omitted only when no explicit ID exists. Overrides never produce an implicit /{id} URL.


URL path modes

ID resolution and URL composition are separate concerns.

Assume:

text
Source:
notes/flutter/hello.md
 
ID:
hello-world

flat

ts
path: {
  mode: "flat",
  prefix: "/n",
}

Result:

text
/n/hello-world

The filesystem directory structure is not exposed in the public URL.

This mode is useful for opaque, location-independent URLs.


preserve

Preserves the source directory structure while placing the ID into the resulting URL.

ts
path: {
  mode: "preserve",
  prefix: "/n",
}

Example:

text
notes/flutter/hello.md
 
↓
 
/n/notes/flutter/hello-world

append

append is also available:

ts
path: {
  mode: "append",
}

The current implementation returns the same path as preserve.

The mode exists separately so it can represent different composition semantics in the future. Do not rely on append having behavior distinct from preserve in the current version.


Index files

index.collapse controls how index.md is represented.

ts
index: {
  collapse: true,
}

For:

text
notes/flutter/index.md

with ID hello-world:

Configuration URL
flat /n/hello-world
preserve + collapse /n/notes/flutter/hello-world
preserve + no collapse /n/notes/flutter/index/hello-world

flat does not use the filesystem directory structure, so index collapsing does not affect it.


Advanced: Custom resolvers

For URL schemes that cannot be expressed with the built-in strategies, resolveId and resolvePath provide escape hatches.

Prefer the built-in options when they are sufficient. Custom resolvers are intended for project-specific identity and URL schemes.

resolveId

resolveId lets you compute the content ID yourself.

ts
permalink({
  resolveId(content) {
    return `post-${content.slug}`;
  },
 
  path: {
    mode: "flat",
    prefix: "/articles",
  },
});

Conceptually:

text
Content
   ↓
resolveId(content)
   ↓
ID
   ↓
built-in path resolver
   ↓
canonical URL

Values returned by resolveId still pass through the same ID validation as built-in IDs.

A custom resolver therefore does not bypass the plugin's normal validation.

Example: derive an ID from custom frontmatter

Suppose your content uses:

md
---
category: flutter
serial: 42
---

You can define a project-specific ID:

ts
permalink({
  resolveId(content) {
    const category = content.frontmatter.category;
    const serial = content.frontmatter.serial;
 
    if (typeof category !== "string") {
      throw new Error("category is required");
    }
 
    if (typeof serial !== "number") {
      throw new Error("serial is required");
    }
 
    return `${category}-${serial}`;
  },
 
  path: {
    mode: "flat",
    prefix: "/articles",
  },
});

Result:

text
/articles/flutter-42

Validation of project-specific frontmatter values inside a custom resolver is the resolver's responsibility.


Advanced: resolvePath

resolvePath gives full control over how a resolved ID becomes a public URL.

ts
permalink({
  frontmatter: "id",
 
  id: {
    strategy: "frontmatter-or-hash",
  },
 
  resolvePath({ content, id }) {
    return `/articles/${id}`;
  },
});

Result:

text
/articles/hello-world

The returned value must be a site-local absolute path.

text
/articles/hello     valid
/articles/hello/    valid
articles/hello      invalid
https://example.com invalid

The result still passes through the plugin's normal URL normalization, validation, and collision detection.


Combining resolveId and resolvePath

Both resolvers can be used together when both identity and URL structure are project-specific.

For example:

md
---
published: 2026-09-28
article_id: riebeckite-permalink
---
ts
permalink({
  resolveId(content) {
    const value = content.frontmatter.article_id;
 
    if (typeof value !== "string") {
      throw new Error("article_id is required");
    }
 
    return value;
  },
 
  resolvePath({ content, id }) {
    const published = content.frontmatter.published;
 
    if (typeof published !== "string") {
      throw new Error("published is required");
    }
 
    const year = published.slice(0, 4);
 
    return `/articles/${year}/${id}`;
  },
});

Result:

text
/articles/2026/riebeckite-permalink

Riebeckite still treats only the final resolved URL as the canonical public location.


Choosing between built-in and custom resolution

A useful rule is:

text
Built-in strategies are sufficient
        ↓
Use normal options
 
Only ID generation is special
        ↓
Use resolveId
 
Only URL structure is special
        ↓
Use resolvePath
 
Both are project-specific
        ↓
Use resolveId + resolvePath

For example, creating /n/{id} does not require a custom resolver:

ts
permalink({
  id: {
    strategy: "frontmatter-or-hash",
  },
 
  path: {
    mode: "flat",
    prefix: "/n",
  },
});

This is preferable because the intent is clearer and the configuration remains declarative.


Guarantees with custom resolvers

Using a custom resolver does not bypass the rest of the Permalink Plugin pipeline.

The following behavior is still preserved:

  • ID validation
  • URL normalization
  • URL validation
  • ID collision detection
  • canonical URL collision detection
  • redirect collision detection
  • trailing slash handling
  • canonical public location registration in Core

Custom resolvers change how the ID or path is produced, not how the resulting public location is validated and registered.


A manual permalink override takes precedence over normal ID and path resolution.

For example:

md
---
id: abc
permalink: /about
---

is treated conceptually as:

text
ID candidate
abc
 
Canonical URL
/about

If resolvePath is also configured, the manual permalink override still wins.

This makes it possible to use a general URL strategy while giving a few special pages fixed URLs.


Stateless builds

The Permalink Plugin does not maintain a persistent ID registry.

It does not create or require:

text
.riebeckite/content-ids.json
state.json
SQLite database
KV database

Public locations are derived at build time from:

text
Plugin configuration
+
source content

This makes the same configuration suitable for local builds, CI, and Cloudflare Workers deployments without additional identity state.

With the hash strategy, the source path is part of the identity input. Renaming or moving a file therefore changes its derived ID.

Use explicit frontmatter IDs for content whose URL must survive source-file moves.


Rename and move behavior

URL stability depends on both the ID strategy and path mode.

ID strategy Path mode Rename Move
frontmatter flat Preserved Preserved
frontmatter preserve May change Changes
frontmatter append May change Changes
hash flat Changes Changes
hash preserve Changes Changes
hash append Changes Changes

For explicitly managed permanent URLs:

ts
permalink({
  frontmatter: "id",
 
  id: {
    strategy: "frontmatter",
  },
 
  path: {
    mode: "flat",
  },
});

For existing vaults where adding IDs everywhere is undesirable:

ts
id: {
  strategy: "frontmatter-or-hash",
}

is usually more convenient.


Validation

IDs

An ID must represent a single URL path segment.

The following are rejected:

  • empty values
  • /
  • #
  • ?
  • whitespace
  • malformed percent-encoding

Values returned by resolveId are subject to the same rules.

Permalinks and redirects must be site-local absolute paths.

The following are rejected:

  • relative paths
  • query strings
  • fragments
  • \
  • invalid //
  • external URLs

Values returned by resolvePath are subject to the same rules.


Collision detection

The plugin detects conflicting public locations during the build.

This includes:

  • ID ↔ ID
  • canonical URL ↔ canonical URL
  • canonical URL ↔ redirect
  • redirect ↔ redirect

For example:

text
a.md
→ /about
 
b.md
→ /about

fails the build.

The following also fails:

text
a.md canonical
→ /about
 
b.md redirect
→ /about

The plugin does not silently append suffixes to resolve collisions.

This prevents public URLs from changing based on build or content ordering.


Inspecting resolved URLs

Resolved values can be inspected through Riebeckite's existing content inspection command:

sh
riebeckite inspect content --list

When the Permalink Plugin is enabled, the output can expose the resolved ID, ID source, and permalink for each entry.

For example:

text
PATH                 ID            ID SOURCE     PERMALINK
notes/a.md           K7m3Qp8d...   derived       /n/K7m3Qp8d...
notes/about.md       about         frontmatter   /about

The exact output format may vary between CLI versions.


Exports

Functions

  • permalink(options?)
  • permalinkPlugin(options?)

Both create the Permalink Plugin.

Types

  • PermalinkOptions
  • PermalinkIdStrategy
  • PermalinkPathMode
  • RedirectStatus

Use the exported types when building type-safe project-specific resolver configuration.


Configuration examples

Existing Obsidian vault

Hide the filesystem layout without requiring frontmatter changes across the vault:

ts
permalink({
  frontmatter: "id",
 
  id: {
    strategy: "frontmatter-or-hash",
    length: 12,
  },
 
  path: {
    mode: "flat",
    prefix: "/n",
  },
});

Explicit permanent IDs

Require every entry to define its identity explicitly:

ts
permalink({
  frontmatter: "id",
 
  id: {
    strategy: "frontmatter",
  },
 
  path: {
    mode: "flat",
    prefix: "/n",
  },
});

Preserve directory structure

ts
permalink({
  id: {
    strategy: "frontmatter-or-hash",
  },
 
  path: {
    mode: "preserve",
    prefix: "",
  },
});

Fully custom URL scheme

ts
permalink({
  resolveId(content) {
    // Project-specific identity.
    return "...";
  },
 
  resolvePath({ content, id }) {
    // Project-specific public URL.
    return `/articles/${id}`;
  },
});

See also

History

1 changesCollapseExpand
1 + <!-- Generated from packages/plugins/permalink/README.md. Do not edit this page directly; edit the package README and run `pnpm docs:sync`. -->
2 +
3 + # Permalink
4 +
5 + Stable, configurable public URLs (permalinks) for Riebeckite content.
6 +
7 + Instead of coupling public URLs directly to the filesystem layout, this plugin can build URLs from frontmatter IDs, deterministic path-derived IDs, or custom resolvers. Legacy URLs can also be registered as redirects in the same configuration.
8 +
9 + [日本語](./permalink.md)
10 +
11 + ## Basic usage
12 +
13 + ```ts
14 + import { defineConfig } from "@riebeckite/core";
15 + import { permalink } from "@riebeckite/plugin-permalink";
16 +
17 + export default defineConfig({
18 + plugins: [
19 + permalink({
20 + frontmatter: "id",
21 + id: {
22 + strategy: "frontmatter-or-hash",
23 + length: 12,
24 + },
25 + path: {
26 + mode: "flat",
27 + prefix: "/n",
28 + trailingSlash: false,
29 + },
30 + redirects: {
31 + frontmatter: "redirect_from",
32 + status: 308,
33 + },
34 + }),
35 + ],
36 + });
37 + ```
38 +
39 + Given the following content:
40 +
41 + ```md
42 + ---
43 + id: hello-world
44 + ---
45 +
46 + # Hello
47 + ```
48 +
49 + with `path.mode: "flat"` and `path.prefix: "/n"`, the public URL becomes:
50 +
51 + ```text
52 + /n/hello-world
53 + ```
54 +
55 + With the default `frontmatter-or-hash` strategy, content without a frontmatter ID receives a deterministic ID derived from its file path.
56 +
57 + This means an existing Obsidian vault does not need an `id` field added to every file.
58 +
59 + ---
60 +
61 + ## How it works
62 +
63 + `permalink()` uses the build-time `resolveContentLocations` extension point to resolve the public location of each content entry.
64 +
65 + Conceptually:
66 +
67 + ```text
68 + Content
69 + │
70 + ├─ frontmatter
71 + ├─ slug
72 + └─ source path
73 + │
74 + ▼
75 + @riebeckite/plugin-permalink
76 + │
77 + ├─ resolve ID
78 + ├─ resolve path
79 + ├─ normalize
80 + ├─ validate
81 + └─ redirects
82 + │
83 + ▼
84 + ContentPublicLocation
85 + │
86 + ├─ permalink
87 + ├─ redirects
88 + └─ metadata
89 + ```
90 +
91 + Core treats the resolved `permalink` as the canonical public URL.
92 +
93 + The same canonical URL is then used by Wikilinks, backlinks, search, the content graph, SEO canonical URLs, sitemaps, RSS / Atom / JSON Feed, and other consumers.
94 +
95 + Without this plugin, Riebeckite still resolves every URL through the Core default public location resolver, `resolveDefaultContentLocation`, which maps `index` to `/` and every other entry to `/{slug}`. That default is a first-class Core policy, not a fallback.
96 +
97 + ---
98 +
99 + ## Frontmatter
100 +
101 + ### ID
102 +
103 + By default, the plugin reads the `id` field:
104 +
105 + ```md
106 + ---
107 + id: hello-world
108 + ---
109 + ```
110 +
111 + The field name is configurable:
112 +
113 + ```ts
114 + permalink({
115 + frontmatter: "permalink-id",
116 + });
117 + ```
118 +
119 + ```md
120 + ---
121 + permalink-id: hello-world
122 + ---
123 + ```
124 +
125 + `frontmatter` refers to a top-level frontmatter field.
126 +
127 + ### Permalink override
128 +
129 + A specific entry can override its public URL entirely:
130 +
131 + ```md
132 + ---
133 + id: about-page
134 + permalink: /about
135 + ---
136 + ```
137 +
138 + The explicit permalink takes precedence over normal ID and path resolution.
139 +
140 + The content ID and its public location remain separate concepts:
141 +
142 + ```text
143 + ID
144 + about-page
145 +
146 + Canonical URL
147 + /about
148 + ```
149 +
150 + The field name can also be changed:
151 +
152 + ```ts
153 + permalink({
154 + override: {
155 + frontmatter: "url",
156 + },
157 + });
158 + ```
159 +
160 + ### Redirects
161 +
162 + Legacy URLs can be declared in frontmatter:
163 +
164 + ```md
165 + ---
166 + id: hello-world
167 + redirect_from:
168 + - /posts/hello
169 + - /blog/2024/hello-world
170 + ---
171 + ```
172 +
173 + If the canonical URL is:
174 +
175 + ```text
176 + /n/hello-world
177 + ```
178 +
179 + the redirects resolve to it:
180 +
181 + ```text
182 + /posts/hello
183 + ↓ 308
184 + /n/hello-world
185 +
186 + /blog/2024/hello-world
187 + ↓ 308
188 + /n/hello-world
189 + ```
190 +
191 + Configure the field and status with:
192 +
193 + ```ts
194 + permalink({
195 + redirects: {
196 + frontmatter: "redirect_from",
197 + status: 308,
198 + },
199 + });
200 + ```
201 +
202 + `redirect_from` accepts either a string or an array of strings.
203 +
204 + ---
205 +
206 + ## Options
207 +
208 + | Option | Type | Default | Description |
209 + | --- | --- | --- | --- |
210 + | `frontmatter` | `string` | `"id"` | Top-level frontmatter field used as the ID |
211 + | `id.strategy` | `"frontmatter" \| "hash" \| "frontmatter-or-hash"` | `"frontmatter-or-hash"` | How IDs are resolved |
212 + | `id.length` | `number` | `12` | Length of path-derived hash IDs (6–43) |
213 + | `path.mode` | `"flat" \| "preserve" \| "append"` | `"flat"` | How the resolved ID is placed into the public URL |
214 + | `path.prefix` | `string` | `"/n"` | URL prefix. `"/"` or an empty string means no prefix |
215 + | `path.trailingSlash` | `boolean` | `false` | Whether canonical URLs end with `/` |
216 + | `index.collapse` | `boolean` | `true` | Whether the `index` segment is collapsed |
217 + | `override.frontmatter` | `string` | `"permalink"` | Frontmatter field containing a complete permalink override |
218 + | `redirects.frontmatter` | `string` | `"redirect_from"` | Frontmatter field containing legacy URLs |
219 + | `redirects.status` | `301 \| 302 \| 307 \| 308` | `308` | Redirect HTTP status |
220 + | `resolveId` | `(content) => string` | — | Fully customize ID resolution |
221 + | `resolvePath` | `({ content, id }) => string` | — | Fully customize public path generation |
222 +
223 + ---
224 +
225 + ## ID strategies
226 +
227 + ### `frontmatter`
228 +
229 + Uses the configured frontmatter field as the ID.
230 +
231 + ```ts
232 + permalink({
233 + frontmatter: "id",
234 + id: {
235 + strategy: "frontmatter",
236 + },
237 + });
238 + ```
239 +
240 + Every content entry must provide the field. A missing ID causes a build error.
241 +
242 + This is useful when URL identity should be explicitly controlled:
243 +
244 + ```md
245 + ---
246 + id: article-123
247 + ---
248 + ```
249 +
250 + With `flat` mode, renaming or moving the source file does not change the ID or public URL.
251 +
252 + ---
253 +
254 + ### `hash`
255 +
256 + Ignores the frontmatter ID and derives the ID from the source file path.
257 +
258 + ```ts
259 + permalink({
260 + id: {
261 + strategy: "hash",
262 + length: 12,
263 + },
264 + });
265 + ```
266 +
267 + The input path is normalized by:
268 +
269 + - replacing `\` with `/`
270 + - stripping leading `/`
271 + - applying Unicode NFC normalization
272 +
273 + The normalized path is hashed using SHA-256, encoded as base64url, and truncated to `id.length`.
274 +
275 + ```text
276 + notes/flutter/riverpod.md
277 + │
278 + ▼
279 + normalized path
280 + │
281 + ▼
282 + SHA-256
283 + │
284 + ▼
285 + base64url
286 + │
287 + ▼
288 + K7m3Qp8d...
289 + ```
290 +
291 + The same path produces the same ID across operating systems and build environments.
292 +
293 + Because the source path is the input, renaming or moving the file changes the ID.
294 +
295 + ---
296 +
297 + ### `frontmatter-or-hash`
298 +
299 + This is the default strategy.
300 +
301 + ```ts
302 + permalink({
303 + frontmatter: "id",
304 + id: {
305 + strategy: "frontmatter-or-hash",
306 + },
307 + });
308 + ```
309 +
310 + Resolution works as follows:
311 +
312 + ```text
313 + frontmatter ID exists
314 + ↓
315 + use frontmatter ID
316 +
317 + frontmatter ID missing
318 + ↓
319 + derive ID from path
320 + ```
321 +
322 + This is useful for existing Obsidian vaults: content works without modification, while important entries can opt into explicit stable IDs.
323 +
324 + ---
325 +
326 + ## ID metadata
327 +
328 + The source of a resolved ID is exposed through metadata.
329 +
330 + | `metadata.idSource` | Meaning |
331 + | --- | --- |
332 + | `frontmatter` | Taken from the configured frontmatter field |
333 + | `derived` | Derived from the source path hash |
334 + | `custom` | Returned by `resolveId` |
335 +
336 + A manual `permalink` override changes the URL without removing identity. When the frontmatter ID is present it is still recorded (`metadata.idSource` is `frontmatter`); metadata is omitted only when no explicit ID exists. Overrides never produce an implicit `/{id}` URL.
337 +
338 + ---
339 +
340 + ## URL path modes
341 +
342 + ID resolution and URL composition are separate concerns.
343 +
344 + Assume:
345 +
346 + ```text
347 + Source:
348 + notes/flutter/hello.md
349 +
350 + ID:
351 + hello-world
352 + ```
353 +
354 + ### `flat`
355 +
356 + ```ts
357 + path: {
358 + mode: "flat",
359 + prefix: "/n",
360 + }
361 + ```
362 +
363 + Result:
364 +
365 + ```text
366 + /n/hello-world
367 + ```
368 +
369 + The filesystem directory structure is not exposed in the public URL.
370 +
371 + This mode is useful for opaque, location-independent URLs.
372 +
373 + ---
374 +
375 + ### `preserve`
376 +
377 + Preserves the source directory structure while placing the ID into the resulting URL.
378 +
379 + ```ts
380 + path: {
381 + mode: "preserve",
382 + prefix: "/n",
383 + }
384 + ```
385 +
386 + Example:
387 +
388 + ```text
389 + notes/flutter/hello.md
390 +
391 + ↓
392 +
393 + /n/notes/flutter/hello-world
394 + ```
395 +
396 + ---
397 +
398 + ### `append`
399 +
400 + `append` is also available:
401 +
402 + ```ts
403 + path: {
404 + mode: "append",
405 + }
406 + ```
407 +
408 + The current implementation returns the same path as `preserve`.
409 +
410 + The mode exists separately so it can represent different composition semantics in the future. Do not rely on `append` having behavior distinct from `preserve` in the current version.
411 +
412 + ---
413 +
414 + ## Index files
415 +
416 + `index.collapse` controls how `index.md` is represented.
417 +
418 + ```ts
419 + index: {
420 + collapse: true,
421 + }
422 + ```
423 +
424 + For:
425 +
426 + ```text
427 + notes/flutter/index.md
428 + ```
429 +
430 + with ID `hello-world`:
431 +
432 + | Configuration | URL |
433 + | --- | --- |
434 + | `flat` | `/n/hello-world` |
435 + | `preserve` + collapse | `/n/notes/flutter/hello-world` |
436 + | `preserve` + no collapse | `/n/notes/flutter/index/hello-world` |
437 +
438 + `flat` does not use the filesystem directory structure, so index collapsing does not affect it.
439 +
440 + ---
441 +
442 + ## Advanced: Custom resolvers
443 +
444 + For URL schemes that cannot be expressed with the built-in strategies, `resolveId` and `resolvePath` provide escape hatches.
445 +
446 + Prefer the built-in options when they are sufficient. Custom resolvers are intended for project-specific identity and URL schemes.
447 +
448 + ### `resolveId`
449 +
450 + `resolveId` lets you compute the content ID yourself.
451 +
452 + ```ts
453 + permalink({
454 + resolveId(content) {
455 + return `post-${content.slug}`;
456 + },
457 +
458 + path: {
459 + mode: "flat",
460 + prefix: "/articles",
461 + },
462 + });
463 + ```
464 +
465 + Conceptually:
466 +
467 + ```text
468 + Content
469 + ↓
470 + resolveId(content)
471 + ↓
472 + ID
473 + ↓
474 + built-in path resolver
475 + ↓
476 + canonical URL
477 + ```
478 +
479 + Values returned by `resolveId` still pass through the same ID validation as built-in IDs.
480 +
481 + A custom resolver therefore does not bypass the plugin's normal validation.
482 +
483 + #### Example: derive an ID from custom frontmatter
484 +
485 + Suppose your content uses:
486 +
487 + ```md
488 + ---
489 + category: flutter
490 + serial: 42
491 + ---
492 + ```
493 +
494 + You can define a project-specific ID:
495 +
496 + ```ts
497 + permalink({
498 + resolveId(content) {
499 + const category = content.frontmatter.category;
500 + const serial = content.frontmatter.serial;
501 +
502 + if (typeof category !== "string") {
503 + throw new Error("category is required");
504 + }
505 +
506 + if (typeof serial !== "number") {
507 + throw new Error("serial is required");
508 + }
509 +
510 + return `${category}-${serial}`;
511 + },
512 +
513 + path: {
514 + mode: "flat",
515 + prefix: "/articles",
516 + },
517 + });
518 + ```
519 +
520 + Result:
521 +
522 + ```text
523 + /articles/flutter-42
524 + ```
525 +
526 + Validation of project-specific frontmatter values inside a custom resolver is the resolver's responsibility.
527 +
528 + ---
529 +
530 + ## Advanced: `resolvePath`
531 +
532 + `resolvePath` gives full control over how a resolved ID becomes a public URL.
533 +
534 + ```ts
535 + permalink({
536 + frontmatter: "id",
537 +
538 + id: {
539 + strategy: "frontmatter-or-hash",
540 + },
541 +
542 + resolvePath({ content, id }) {
543 + return `/articles/${id}`;
544 + },
545 + });
546 + ```
547 +
548 + Result:
549 +
550 + ```text
551 + /articles/hello-world
552 + ```
553 +
554 + The returned value must be a site-local absolute path.
555 +
556 + ```text
557 + /articles/hello valid
558 + /articles/hello/ valid
559 + articles/hello invalid
560 + https://example.com invalid
561 + ```
562 +
563 + The result still passes through the plugin's normal URL normalization, validation, and collision detection.
564 +
565 + ---
566 +
567 + ## Combining `resolveId` and `resolvePath`
568 +
569 + Both resolvers can be used together when both identity and URL structure are project-specific.
570 +
571 + For example:
572 +
573 + ```md
574 + ---
575 + published: 2026-09-28
576 + article_id: riebeckite-permalink
577 + ---
578 + ```
579 +
580 + ```ts
581 + permalink({
582 + resolveId(content) {
583 + const value = content.frontmatter.article_id;
584 +
585 + if (typeof value !== "string") {
586 + throw new Error("article_id is required");
587 + }
588 +
589 + return value;
590 + },
591 +
592 + resolvePath({ content, id }) {
593 + const published = content.frontmatter.published;
594 +
595 + if (typeof published !== "string") {
596 + throw new Error("published is required");
597 + }
598 +
599 + const year = published.slice(0, 4);
600 +
601 + return `/articles/${year}/${id}`;
602 + },
603 + });
604 + ```
605 +
606 + Result:
607 +
608 + ```text
609 + /articles/2026/riebeckite-permalink
610 + ```
611 +
612 + Riebeckite still treats only the final resolved URL as the canonical public location.
613 +
614 + ---
615 +
616 + ## Choosing between built-in and custom resolution
617 +
618 + A useful rule is:
619 +
620 + ```text
621 + Built-in strategies are sufficient
622 + ↓
623 + Use normal options
624 +
625 + Only ID generation is special
626 + ↓
627 + Use resolveId
628 +
629 + Only URL structure is special
630 + ↓
631 + Use resolvePath
632 +
633 + Both are project-specific
634 + ↓
635 + Use resolveId + resolvePath
636 + ```
637 +
638 + For example, creating `/n/{id}` does not require a custom resolver:
639 +
640 + ```ts
641 + permalink({
642 + id: {
643 + strategy: "frontmatter-or-hash",
644 + },
645 +
646 + path: {
647 + mode: "flat",
648 + prefix: "/n",
649 + },
650 + });
651 + ```
652 +
653 + This is preferable because the intent is clearer and the configuration remains declarative.
654 +
655 + ---
656 +
657 + ## Guarantees with custom resolvers
658 +
659 + Using a custom resolver does not bypass the rest of the Permalink Plugin pipeline.
660 +
661 + The following behavior is still preserved:
662 +
663 + - ID validation
664 + - URL normalization
665 + - URL validation
666 + - ID collision detection
667 + - canonical URL collision detection
668 + - redirect collision detection
669 + - trailing slash handling
670 + - canonical public location registration in Core
671 +
672 + Custom resolvers change how the ID or path is produced, not how the resulting public location is validated and registered.
673 +
674 + ---
675 +
676 + ## Manual permalink precedence
677 +
678 + A manual permalink override takes precedence over normal ID and path resolution.
679 +
680 + For example:
681 +
682 + ```md
683 + ---
684 + id: abc
685 + permalink: /about
686 + ---
687 + ```
688 +
689 + is treated conceptually as:
690 +
691 + ```text
692 + ID candidate
693 + abc
694 +
695 + Canonical URL
696 + /about
697 + ```
698 +
699 + If `resolvePath` is also configured, the manual permalink override still wins.
700 +
701 + This makes it possible to use a general URL strategy while giving a few special pages fixed URLs.
702 +
703 + ---
704 +
705 + ## Stateless builds
706 +
707 + The Permalink Plugin does not maintain a persistent ID registry.
708 +
709 + It does not create or require:
710 +
711 + ```text
712 + .riebeckite/content-ids.json
713 + state.json
714 + SQLite database
715 + KV database
716 + ```
717 +
718 + Public locations are derived at build time from:
719 +
720 + ```text
721 + Plugin configuration
722 + +
723 + source content
724 + ```
725 +
726 + This makes the same configuration suitable for local builds, CI, and Cloudflare Workers deployments without additional identity state.
727 +
728 + With the `hash` strategy, the source path is part of the identity input. Renaming or moving a file therefore changes its derived ID.
729 +
730 + Use explicit frontmatter IDs for content whose URL must survive source-file moves.
731 +
732 + ---
733 +
734 + ## Rename and move behavior
735 +
736 + URL stability depends on both the ID strategy and path mode.
737 +
738 + | ID strategy | Path mode | Rename | Move |
739 + | --- | --- | --- | --- |
740 + | frontmatter | flat | Preserved | Preserved |
741 + | frontmatter | preserve | May change | Changes |
742 + | frontmatter | append | May change | Changes |
743 + | hash | flat | Changes | Changes |
744 + | hash | preserve | Changes | Changes |
745 + | hash | append | Changes | Changes |
746 +
747 + For explicitly managed permanent URLs:
748 +
749 + ```ts
750 + permalink({
751 + frontmatter: "id",
752 +
753 + id: {
754 + strategy: "frontmatter",
755 + },
756 +
757 + path: {
758 + mode: "flat",
759 + },
760 + });
761 + ```
762 +
763 + For existing vaults where adding IDs everywhere is undesirable:
764 +
765 + ```ts
766 + id: {
767 + strategy: "frontmatter-or-hash",
768 + }
769 + ```
770 +
771 + is usually more convenient.
772 +
773 + ---
774 +
775 + ## Validation
776 +
777 + ### IDs
778 +
779 + An ID must represent a single URL path segment.
780 +
781 + The following are rejected:
782 +
783 + - empty values
784 + - `/`
785 + - `#`
786 + - `?`
787 + - whitespace
788 + - malformed percent-encoding
789 +
790 + Values returned by `resolveId` are subject to the same rules.
791 +
792 + ### Permalinks
793 +
794 + Permalinks and redirects must be site-local absolute paths.
795 +
796 + The following are rejected:
797 +
798 + - relative paths
799 + - query strings
800 + - fragments
801 + - `\`
802 + - invalid `//`
803 + - external URLs
804 +
805 + Values returned by `resolvePath` are subject to the same rules.
806 +
807 + ---
808 +
809 + ## Collision detection
810 +
811 + The plugin detects conflicting public locations during the build.
812 +
813 + This includes:
814 +
815 + - ID ↔ ID
816 + - canonical URL ↔ canonical URL
817 + - canonical URL ↔ redirect
818 + - redirect ↔ redirect
819 +
820 + For example:
821 +
822 + ```text
823 + a.md
824 + → /about
825 +
826 + b.md
827 + → /about
828 + ```
829 +
830 + fails the build.
831 +
832 + The following also fails:
833 +
834 + ```text
835 + a.md canonical
836 + → /about
837 +
838 + b.md redirect
839 + → /about
840 + ```
841 +
842 + The plugin does not silently append suffixes to resolve collisions.
843 +
844 + This prevents public URLs from changing based on build or content ordering.
845 +
846 + ---
847 +
848 + ## Inspecting resolved URLs
849 +
850 + Resolved values can be inspected through Riebeckite's existing content inspection command:
851 +
852 + ```sh
853 + riebeckite inspect content --list
854 + ```
855 +
856 + When the Permalink Plugin is enabled, the output can expose the resolved ID, ID source, and permalink for each entry.
857 +
858 + For example:
859 +
860 + ```text
861 + PATH ID ID SOURCE PERMALINK
862 + notes/a.md K7m3Qp8d... derived /n/K7m3Qp8d...
863 + notes/about.md about frontmatter /about
864 + ```
865 +
866 + The exact output format may vary between CLI versions.
867 +
868 + ---
869 +
870 + ## Exports
871 +
872 + ### Functions
873 +
874 + - `permalink(options?)`
875 + - `permalinkPlugin(options?)`
876 +
877 + Both create the Permalink Plugin.
878 +
879 + ### Types
880 +
881 + - `PermalinkOptions`
882 + - `PermalinkIdStrategy`
883 + - `PermalinkPathMode`
884 + - `RedirectStatus`
885 +
886 + Use the exported types when building type-safe project-specific resolver configuration.
887 +
888 + ---
889 +
890 + ## Configuration examples
891 +
892 + ### Existing Obsidian vault
893 +
894 + Hide the filesystem layout without requiring frontmatter changes across the vault:
895 +
896 + ```ts
897 + permalink({
898 + frontmatter: "id",
899 +
900 + id: {
901 + strategy: "frontmatter-or-hash",
902 + length: 12,
903 + },
904 +
905 + path: {
906 + mode: "flat",
907 + prefix: "/n",
908 + },
909 + });
910 + ```
911 +
912 + ### Explicit permanent IDs
913 +
914 + Require every entry to define its identity explicitly:
915 +
916 + ```ts
917 + permalink({
918 + frontmatter: "id",
919 +
920 + id: {
921 + strategy: "frontmatter",
922 + },
923 +
924 + path: {
925 + mode: "flat",
926 + prefix: "/n",
927 + },
928 + });
929 + ```
930 +
931 + ### Preserve directory structure
932 +
933 + ```ts
934 + permalink({
935 + id: {
936 + strategy: "frontmatter-or-hash",
937 + },
938 +
939 + path: {
940 + mode: "preserve",
941 + prefix: "",
942 + },
943 + });
944 + ```
945 +
946 + ### Fully custom URL scheme
947 +
948 + ```ts
949 + permalink({
950 + resolveId(content) {
951 + // Project-specific identity.
952 + return "...";
953 + },
954 +
955 + resolvePath({ content, id }) {
956 + // Project-specific public URL.
957 + return `/articles/${id}`;
958 + },
959 + });
960 + ```
961 +
962 + ---
963 +
964 + ## See also
965 +
966 + - [Plugin guide](../reference/plugin-api.en.md)
967 + - [Content system](../framework/content-system.en.md)
968 +