@riebeckite/plugin-permalink 0.0.12 → 0.0.13

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