alinea 1.7.0-preview.8 → 1.7.0

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 (83) hide show
  1. package/dist/.init/content/pages/welcome.json +1 -1
  2. package/dist/LICENSES.md +62 -62
  3. package/dist/adapter/next/handler.js +1 -1
  4. package/dist/backend/api/OAuth2.d.ts +6 -0
  5. package/dist/backend/api/OAuth2.js +35 -13
  6. package/dist/backend/router/Router.d.ts +15 -0
  7. package/dist/backend/router/Router.js +3 -0
  8. package/dist/backend/util/ExecGit.d.ts +2 -2
  9. package/dist/bundled.d.ts +297 -235
  10. package/dist/chunks/{chunk-VE4QBNQR.js → chunk-2X5P2RZV.js} +10 -5
  11. package/dist/chunks/{chunk-POPX5CXB.js → chunk-WKC37UOZ.js} +12 -6
  12. package/dist/cli/Serve.js +1 -1
  13. package/dist/cli/bin.js +1 -1
  14. package/dist/cli/serve/CreateLocalServer.js +34 -4
  15. package/dist/cloud/CloudRemote.d.ts +2 -2
  16. package/dist/cloud/CloudRemote.js +8 -2
  17. package/dist/core/Graph.d.ts +1 -1
  18. package/dist/core/Type.d.ts +2 -0
  19. package/dist/core/db/EntryIndex.d.ts +3 -1
  20. package/dist/core/db/EntryIndex.js +15 -3
  21. package/dist/core/db/LocalDB.d.ts +1 -1
  22. package/dist/core/db/Operation.js +3 -1
  23. package/dist/core/db/WriteableGraph.d.ts +1 -1
  24. package/dist/core/media/IsImage.d.ts +1 -1
  25. package/dist/core/media/MediaTypes.d.ts +1 -1
  26. package/dist/core/media/Summary.d.ts +2 -2
  27. package/dist/core/shape/ListShape.d.ts +1 -1
  28. package/dist/core/source/FSSource.d.ts +2 -2
  29. package/dist/core/source/Source.d.ts +2 -2
  30. package/dist/core/source/Tree.d.ts +3 -3
  31. package/dist/core/source/Utils.js +4 -1
  32. package/dist/core/util/ArrayBuffers.d.ts +1 -1
  33. package/dist/core/util/BufferToBase64.d.ts +2 -2
  34. package/dist/core/util/JWT.d.ts +11 -2
  35. package/dist/core/util/JWT.js +16 -12
  36. package/dist/dashboard/DashboardNav.d.ts +8 -8
  37. package/dist/dashboard/Routes.d.ts +25 -25
  38. package/dist/dashboard/atoms/DbAtoms.d.ts +1 -1
  39. package/dist/dashboard/atoms/Edits.d.ts +3 -3
  40. package/dist/dashboard/atoms/EntryEditorAtoms.d.ts +27 -27
  41. package/dist/dashboard/atoms/EntryEditorAtoms.js +3 -1
  42. package/dist/dashboard/atoms/EntrySummaryAtoms.d.ts +1 -1
  43. package/dist/dashboard/atoms/NavigationAtoms.d.ts +9 -9
  44. package/dist/dashboard/atoms/PreferencesAtoms.d.ts +1 -1
  45. package/dist/dashboard/atoms/RouterAtoms.d.ts +1 -1
  46. package/dist/dashboard/boot/LoadWorker.js +3 -2
  47. package/dist/dashboard/editor/DefaultViews.d.ts +1 -1
  48. package/dist/dashboard/editor/UseField.js +8 -1
  49. package/dist/dashboard/hook/UseNav.d.ts +4 -4
  50. package/dist/dashboard/hook/UseRoot.d.ts +2 -2
  51. package/dist/dashboard/hook/UseWorkspace.d.ts +3 -3
  52. package/dist/dashboard/util/Loader.d.ts +1 -1
  53. package/dist/dashboard/view/InputLabel.d.ts +1 -1
  54. package/dist/dashboard/view/explorer/ExplorerRow.d.ts +1 -1
  55. package/dist/field/link/EntryLink.d.ts +1 -1
  56. package/dist/field/link/FileLink.d.ts +2 -2
  57. package/dist/field/link/LinkField.view.js +18 -0
  58. package/dist/field/link/UrlLink.d.ts +1 -1
  59. package/dist/field/richtext/Extensions.d.ts +1 -0
  60. package/dist/field/richtext/Extensions.js +118 -9
  61. package/dist/field/richtext/Toolbar.d.ts +6 -0
  62. package/dist/field/richtext/Toolbar.js +7 -0
  63. package/dist/index.css +4 -0
  64. package/dist/picker/url/UrlPicker.js +1 -1
  65. package/dist/query.d.ts +85 -85
  66. package/dist/test/example.d.ts +58 -58
  67. package/dist/test/schema/DemoHome.d.ts +17 -17
  68. package/dist/test/schema/DemoRecipe.d.ts +10 -10
  69. package/dist/test/schema/DemoRecipes.d.ts +2 -2
  70. package/dist/ui/Button.stories.d.ts +1 -1
  71. package/dist/ui/Ellipsis.d.ts +1 -1
  72. package/dist/ui/Stack.d.ts +4 -4
  73. package/dist/ui/Typo.d.ts +2 -2
  74. package/dist/ui/icons/IcRoundAttachFile.d.ts +2 -0
  75. package/dist/ui/icons/IcRoundAttachFile.js +10 -0
  76. package/dist/ui/icons/IcRoundFormatPaint.d.ts +2 -0
  77. package/dist/ui/icons/IcRoundFormatPaint.js +10 -0
  78. package/dist/ui/icons/IcRoundPanorama.d.ts +2 -0
  79. package/dist/ui/icons/IcRoundPanorama.js +10 -0
  80. package/dist/ui/util/CreateTypo.d.ts +2 -2
  81. package/llms-full.txt +4278 -0
  82. package/package.json +10 -5
  83. package/readme.md +4 -0
package/llms-full.txt ADDED
@@ -0,0 +1,4278 @@
1
+ # Alinea CMS Docs
2
+
3
+ ### Introduction (/docs)
4
+ Alinea is an open source headless CMS written in Typescript. It stores content in flat files in your repository so they can be checked into Git. This means you can roll back to a previous version, compare changes, and track who made changes. Content is bundled with deploys so it can be retrieved without network roundtrips.
5
+
6
+ Image: dashboard (/dashboard)
7
+
8
+ ### Configuration (/docs/configuration)
9
+ All configuration can be managed from the `cms.ts` file. This file is created in the project root (or your `src` folder if it exists) during `alinea init`.
10
+
11
+ File: cms.ts
12
+ ```
13
+ import {createCMS} from 'alinea/next'
14
+
15
+ export const cms = createCMS({
16
+ baseUrl: {
17
+ development: 'http://localhost:3000',
18
+ production: 'https://alineacms.com'
19
+ },
20
+ enableDrafts: true,
21
+ preview: true,
22
+ schema: {...},
23
+ workspaces: {...}
24
+ })
25
+ ```
26
+
27
+ ### `baseUrl`
28
+
29
+ The URL of the frontend where Alinea is used.
30
+
31
+ ### `enableDrafts`
32
+
33
+ Allows content editors to save and preview unpublished changes before publishing.
34
+
35
+ ### `preview`
36
+
37
+ Display an iframe with live previews on the side of the editor.
38
+
39
+ ### `schema`
40
+
41
+ Describe the structure of your content using a [collection of Types](/docs/configuration/schema).
42
+
43
+ ### `workspaces`
44
+
45
+ Content can be bundled in separate [Workspaces](/docs/configuration/workspaces). Defining at least one is required.
46
+
47
+ ### `syncInterval`
48
+
49
+ Optionally set the interval in seconds at which the frontend will poll for updates.
50
+
51
+ ## Good to know
52
+
53
+ ### Dealing with errors
54
+
55
+ Your config file is read and executed during the `alinea dev` and `alinea build` [CLI](/docs/reference/cli) commands. If anything goes wrong, you might see an error such as:
56
+
57
+ ```shellscript
58
+ Error: Fail
59
+ at file:///home/alineacms/alinea/node_modules/@alinea/generated/config.js?1706175675574:419:7
60
+ at ModuleJob.run (node:internal/modules/esm/module_job:194:25)
61
+ ```
62
+
63
+ To debug these situations Alinea compiles your config file with an included source map. To enable node to read the source map and report correct positions you can enable the Node.js `--enable-source-maps` flag. You can add it to the scripts in package.json:
64
+
65
+ ```tsx
66
+ {
67
+ "scripts": {
68
+ "dev": "NODE_OPTIONS=--enable-source-maps alinea dev -- next dev",
69
+ "build": "NODE_OPTIONS=--enable-source-maps alinea build -- next build"
70
+ }
71
+ }
72
+ ```
73
+
74
+ Note (info): If you're developing on Windows you can use [cross-env](https://www.npmjs.com/package/cross-env) to achieve the same.
75
+
76
+ The error will now point to the right file:
77
+
78
+ ```shellscript
79
+ Error: Fail
80
+ at <anonymous> (/home/alinea/apps/dev/cms.ts:278:7)
81
+ at ModuleJob.run (node:internal/modules/esm/module_job:194:25)
82
+ ```
83
+
84
+ ### Fields (/docs/configuration/fields)
85
+ Fields make data editable. Alinea ships with a lot of field types but can easily be expanded with [custom fields](/docs/configuration/fields/custom-fields).
86
+
87
+ ```
88
+ import {Config, Field} from 'alinea'
89
+ import {FieldOptions, ScalarField, WithoutLabel} from 'alinea/core'
90
+ import {InputLabel, useField} from 'alinea/dashboard'
91
+
92
+ interface RangeFieldOptions extends FieldOptions<number> {
93
+ min?: number
94
+ max?: number
95
+ }
96
+
97
+ class RangeField extends ScalarField<number, RangeFieldOptions> {
98
+ }
99
+
100
+ // The constructor function is used to create fields in our schema
101
+ // later on. It is usually passed a label and options.
102
+ export function range(label: string, options: WithoutLabel<RangeFieldOptions> = {}): RangeField {
103
+ return new RangeField({
104
+ options: {label, ...options},
105
+ view: RangeInput
106
+ })
107
+ }
108
+
109
+ interface RangeInputProps {
110
+ field: RangeField
111
+ }
112
+
113
+ // To view our field we can create a React component.
114
+ // This component can call the useInput hook to receive the
115
+ // current value and a method to update it.
116
+ function RangeInput({field}: RangeInputProps) {
117
+ const {value, mutator, options} = useField(field)
118
+ const {min = 0, max = 10} = options
119
+ return (
120
+ <InputLabel {...options}>
121
+ <input
122
+ type="range"
123
+ min={min} max={max}
124
+ value={value}
125
+ onChange={e => mutator(Number(e.target.value))}
126
+ />
127
+ </InputLabel>
128
+ )
129
+ }
130
+
131
+ export default Config.type('Kitchen sink', {
132
+ fields: {
133
+ ...Field.tabs(
134
+ Field.tab('Basic fields', {
135
+ fields: {
136
+ title: Field.text('Text field'),
137
+ path: Field.path('Path field', {
138
+ help: 'Creates a slug of the value of another field'
139
+ }),
140
+ richText: Field.richText('Rich text field'),
141
+ select: Field.select('Select field', {
142
+ options: {
143
+ a: 'Option a',
144
+ b: 'Option b'
145
+ }
146
+ }),
147
+ number: Field.number('Number field', {
148
+ minValue: 0,
149
+ maxValue: 10
150
+ }),
151
+ check: Field.check('Check field', {label: 'Check me please'}),
152
+ date: Field.date('Date field'),
153
+ code: Field.code('Code field')
154
+ }
155
+ }),
156
+ Field.tab('Link fields', {
157
+ fields: {
158
+ externalLink: Field.url('External link'),
159
+ entry: Field.entry('Internal link'),
160
+ linkMultiple: Field.link.multiple('Mixed links, multiple'),
161
+ image: Field.entry('Image link'),
162
+ file: Field.entry('File link')
163
+ }
164
+ }),
165
+
166
+ Field.tab('List fields', {
167
+ fields: {
168
+ list: Field.list('My list field', {
169
+ schema: {
170
+ Text: Config.type('Text', {
171
+ fields: {
172
+ title: Field.text('Item title'),
173
+ text: Field.richText('Item body text')
174
+ }
175
+ }),
176
+ Image: Config.type('Image', {
177
+ fields: {
178
+ image: Field.image('Image')
179
+ }
180
+ })
181
+ }
182
+ })
183
+ }
184
+ }),
185
+ Field.tab('Inline fields', {
186
+ fields: {
187
+ street: Field.text('Street', {width: 0.6, inline: true, multiline: true}),
188
+ streetNr: Field.text('Number', {width: 0.2, inline: true}),
189
+ box: Field.text('Box', {width: 0.2, inline: true}),
190
+ zip: Field.text('Zipcode', {width: 0.2, inline: true}),
191
+ city: Field.text('City', {width: 0.4, inline: true}),
192
+ country: Field.text('Country', {
193
+ width: 0.4,
194
+ inline: true
195
+ })
196
+ }
197
+ }),
198
+ Field.tab('Custom fields', {
199
+ fields: {
200
+ range: range('Range field')
201
+ }
202
+ })
203
+ )
204
+ }
205
+ })
206
+ ```
207
+
208
+ ## Configuration
209
+
210
+ While every field will have unique properties, there are a few properties that are generally available.
211
+
212
+ ### `initialValue`
213
+
214
+ Prefill the fields value.
215
+
216
+ ### `hidden`
217
+
218
+ Hide this field in the dashboard but keep its value intact.
219
+
220
+ ### `readOnly`
221
+
222
+ Mark field data as read-only.
223
+
224
+ ### `help`
225
+
226
+ Display a help text next to the fields label.
227
+
228
+ ### `inline`
229
+
230
+ Show a minimal version of the field. In most cases this will mean the input label will be hidden, and the label will show up as a placeholder instead.
231
+
232
+ ### `width`
233
+
234
+ Setting a width value will scale the fields width down, use a number between 0 and 1. This allows you to compose the dashboard UI better based on the content of the fields.
235
+
236
+ ### `shared`
237
+
238
+ Fields can be persisted over all languages if your content is [localised](/docs/reference/internationalization) by setting the `shared` option to `true`. When the entry is published the field data is copied to other locales. This is currently only supported on the root level, not on nested fields.
239
+
240
+ ```
241
+ import {Config, Field} from 'alinea'
242
+
243
+ const Type = Config.type('Persist', {
244
+ fields: {
245
+ // Persist field data over all locales
246
+ sharedField: Field.text('Shared text', {shared: true})
247
+ }
248
+ })
249
+ ```
250
+
251
+ ### `required`
252
+
253
+ The `required` option will make sure the field value is not empty when saving when set to `true`.
254
+
255
+ ### `validate`
256
+
257
+ The `validate` option can be used to validate the field value using a custom function. The function should return `true` if the value is valid, `false` if it is not valid and a string if it is not valid and a message should be shown to the user.
258
+
259
+ ```
260
+ import {Config, Field} from 'alinea'
261
+
262
+ Field.text('Hello field', {
263
+ help: 'This field only accepts "hello" as a value',
264
+ validate(value) {
265
+ if (value !== 'hello') return 'Only "hello" is allowed!'
266
+ }
267
+ })
268
+ ```
269
+
270
+ ## Conditional configuration
271
+
272
+ All field configuration can be adjusted based on the value of other fields. After defining fields in a [Type](/docs/configuration/schema/type) a tracker function can be set up. The tracker function takes a reference to a field and a subscription function. In the subscription function field values can be retrieved and new options returned.
273
+
274
+ ### Example
275
+
276
+ ```
277
+ import {Config, Field} from 'alinea'
278
+
279
+ const Example = Config.type('Conditional example', {
280
+ fields: {
281
+ textField: Field.text('Text field'),
282
+ readOnly: Field.check('Make read-only'),
283
+ hidden: Field.check('Hide field')
284
+ }
285
+ })
286
+
287
+ Config.track.options(Example.textField, get => {
288
+ const textField = get(Example.textField)
289
+ const readOnly = get(Example.readOnly)
290
+ const hidden = get(Example.hidden)
291
+ return {
292
+ readOnly,
293
+ hidden,
294
+ help: `Text has ${textField.length} characters`
295
+ }
296
+ })
297
+ ```
298
+
299
+ ```
300
+ import {Config, Field} from 'alinea'
301
+
302
+ const Example = Config.type('Conditional example', {
303
+ fields: {
304
+ textField: Field.text('Text field'),
305
+ readOnly: Field.check('Make read-only'),
306
+ hidden: Field.check('Hide field')
307
+ }
308
+ })
309
+
310
+ Config.track.options(Example.textField, get => {
311
+ const textField = get(Example.textField)
312
+ const readOnly = get(Example.readOnly)
313
+ const hidden = get(Example.hidden)
314
+ return {
315
+ readOnly,
316
+ hidden,
317
+ help: `Text has ${textField.length} characters`
318
+ }
319
+ })
320
+
321
+ export default Example
322
+ ```
323
+
324
+ ### Check (/docs/configuration/fields/check)
325
+ A check field is used to input boolean data.
326
+
327
+ ```
328
+ import {Field} from 'alinea'
329
+
330
+ Field.check('Checkbox without label')
331
+ Field.check('Label ipsum dolor sit amet', {
332
+ description: 'Checkbox with label & description'
333
+ })
334
+ ```
335
+
336
+ ```
337
+ import {Config, Field} from 'alinea'
338
+
339
+ export default Config.type('Check field', {
340
+ fields: {
341
+ check: Field.check('Checkbox without label'),
342
+ checkDescription: Field.check('Label ipsum dolor sit amet', {
343
+ description: 'Checkbox with label & description'
344
+ })
345
+ }
346
+ })
347
+ ```
348
+
349
+ ### Code (/docs/configuration/fields/code)
350
+ A code field is used to input code.
351
+
352
+ ```
353
+ import {Field} from 'alinea'
354
+
355
+ Field.code('My code field', {
356
+ help: 'Paste your code here'
357
+ })
358
+ ```
359
+
360
+ ```
361
+ import {Config, Field} from 'alinea'
362
+
363
+ export default Config.type('Code field', {
364
+ fields: {
365
+ code: Field.code('My code field', {
366
+ help: 'Paste your code here'
367
+ })
368
+ }
369
+ })
370
+ ```
371
+
372
+ ### Custom fields (/docs/configuration/fields/custom-fields)
373
+ It's possible to create custom fields. A field needs a constructor function that users call to create instances of it in their configuration.
374
+
375
+ ## Range field example
376
+
377
+ Let's create a custom field to demonstrate.
378
+
379
+ File: fields/Range.ts
380
+ ```
381
+ import {Field} from 'alinea'
382
+
383
+ export type RangeField = Field.Create<number, {
384
+ min?: number
385
+ max?: number
386
+ }>
387
+
388
+ // The constructor function is used to create fields in our schema
389
+ // later on. It is usually passed a label and options.
390
+ export function range(label: string, options: Field.Options<RangeField> = {}): RangeField {
391
+ return Field.create({
392
+ label,
393
+ options,
394
+ // Point this
395
+ view: '@/fields/RangeField.view'
396
+ })
397
+ }
398
+ ```
399
+
400
+ File: fields/Range.view.tsx
401
+ ```
402
+ import {InputLabel, useField} from 'alinea/dashboard'
403
+ import {RangeField} from './Range'
404
+
405
+ interface RangeViewProps {
406
+ field: RangeField
407
+ }
408
+
409
+ // To view our field we can create a React component.
410
+ // This component can call the useField hook to receive the
411
+ // current value and a method to update it.
412
+ export default function RangeView({field}: RangeViewProps) {
413
+ const {value, mutator, options} = useField(field)
414
+ const {min = 0, max = 10} = options
415
+ return (
416
+ <InputLabel {...options}>
417
+ <input
418
+ type="range"
419
+ min={min} max={max}
420
+ value={value}
421
+ onChange={e => mutator(Number(e.target.value))}
422
+ />
423
+ </InputLabel>
424
+ )
425
+ }
426
+ ```
427
+
428
+ To use the field in your types later call the constructor function:
429
+
430
+ ```
431
+ import {Config} from 'alinea'
432
+ import {range} from './RangeField'
433
+
434
+ Config.type('My type', {
435
+ fields: {
436
+ // ...
437
+ myRangeField: range('A range field', {
438
+ min: 0,
439
+ max: 20
440
+ })
441
+ }
442
+ })
443
+ ```
444
+
445
+ ```
446
+ import {Field, Config} from 'alinea'
447
+ import {InputLabel, useField} from 'alinea/dashboard'
448
+
449
+ export type RangeField = Field.Create<number, {
450
+ min?: number
451
+ max?: number
452
+ }>
453
+
454
+ // The constructor function is used to create fields in our schema
455
+ // later on. It is usually passed a label and options.
456
+ export function range(label: string, options: Field.Options<RangeField> = {}): RangeField {
457
+ return Field.create({
458
+ label,
459
+ options,
460
+ // Point this
461
+ view({field}) {
462
+ const {value, mutator, options} = useField(field)
463
+ const {min = 0, max = 10} = options
464
+ return (
465
+ <InputLabel {...options}>
466
+ <input
467
+ type="range"
468
+ min={min} max={max}
469
+ value={value}
470
+ onChange={e => mutator(Number(e.target.value))}
471
+ />
472
+ </InputLabel>
473
+ )
474
+ }
475
+ })
476
+ }
477
+
478
+ export default Config.type('Custom fields', {
479
+ fields: {
480
+ range: range('A range field', {min: 0, max: 20})
481
+ }
482
+ })
483
+ ```
484
+
485
+ ### Date & time (/docs/configuration/fields/date)
486
+ A date field is used to input a date.
487
+ A time field is used to input a time.
488
+
489
+ ```
490
+ import {Field} from 'alinea'
491
+
492
+ Field.date('Date field')
493
+ Field.time('Time field')
494
+ ```
495
+
496
+ ```
497
+ import {Config, Field} from 'alinea'
498
+
499
+ export default Config.type('Date/time field', {
500
+ fields: {
501
+ date: Field.date('Date field'),
502
+ time: Field.time('Time field')
503
+ }
504
+ })
505
+ ```
506
+
507
+ ## Configuration
508
+
509
+ ### `initialValue`
510
+
511
+ Prefills the field’s value. For example, you can prefill it with today’s date.
512
+
513
+ ```
514
+ const today = new Date().toISOString().split('T')[0];
515
+ Field.date('Date (initialValue: today)')
516
+ ```
517
+
518
+ ```
519
+ import {Config, Field} from 'alinea'
520
+
521
+ const today = new Date().toISOString().split('T')[0];
522
+
523
+ export default Config.type('Date (initialValue)', {
524
+ fields: {
525
+ date: Field.date('Date (initialValue: today)', {initialValue: today}),
526
+ }
527
+ })
528
+ ```
529
+
530
+ ### Entry (/docs/configuration/fields/entry)
531
+ The entry field can be used to link to an internal page.
532
+
533
+ ```
534
+ import {Field} from 'alinea'
535
+
536
+ Field.entry('Single entry link')
537
+
538
+ Field.entry.multiple('Multiple entry links')
539
+ ```
540
+
541
+ ## Configuration
542
+
543
+ ### `condition`
544
+
545
+ Limit the pages shown in the explorer to this condition. Conditions can be built using fields in the same way as described in the [querying content](/docs/content/query#querying-specific-pages) chapter.
546
+
547
+ ```
548
+ import {Field} from 'alinea'
549
+
550
+ Field.entry('Link to author', {
551
+ condition: {
552
+ _type: 'Author'
553
+ }
554
+ })
555
+
556
+ Field.entry('Link to author or writer', {
557
+ condition: {
558
+ _type: {
559
+ in: ['Author', 'Writer']
560
+ }
561
+ }
562
+ })
563
+ ```
564
+
565
+ ### `defaultView`
566
+
567
+ Preset the UI to show rows or thumbnails (possible values: "row", "thumb")
568
+
569
+ ### `inline`
570
+
571
+ Show a minimal version of the field. The field label is hidden.
572
+
573
+ ### `location`
574
+
575
+ Defines the location (workspace and root) where the explorer will be located.
576
+
577
+ ```
578
+ import {Field} from 'alinea'
579
+
580
+ Field.entry('Link to author', {
581
+ location: {
582
+ root: 'pages',
583
+ workspace: 'main'
584
+ }
585
+ })
586
+ ```
587
+
588
+ ### `pickChildren`
589
+
590
+ Choose from a flat list of direct children of the currently edited entry.
591
+
592
+ ```
593
+ import {Field} from 'alinea'
594
+
595
+ Field.entry('Author of this book', {
596
+ condition: {
597
+ _type: 'Author'
598
+ },
599
+ pickChildren: true
600
+ })
601
+ ```
602
+
603
+ ### `max`
604
+
605
+ Limit the amount of rows in case of multiple links.
606
+
607
+ ```
608
+ import {Field} from 'alinea'
609
+
610
+ Field.entry.multiple('Select up to 3 authors', {
611
+ max: 3
612
+ })
613
+ ```
614
+
615
+ ### File (/docs/configuration/fields/file)
616
+ The file field can be used to link to a file.
617
+
618
+ ```
619
+ import {Field} from 'alinea'
620
+
621
+ Field.file('Single file link')
622
+
623
+ Field.file.multiple('Multiple file links')
624
+ ```
625
+
626
+ ```
627
+ import {Config, Field} from 'alinea'
628
+
629
+ export default Config.type('File field', {
630
+ fields: {
631
+ file: Field.file('Single file link'),
632
+ fileMultiple: Field.file.multiple('Multiple file links')
633
+ }
634
+ })
635
+ ```
636
+
637
+ ## Configuration
638
+
639
+ ### `max`
640
+
641
+ Limit the amount of rows in case of multiple links.
642
+
643
+ ### Image (/docs/configuration/fields/image)
644
+ The image field can be used to select an image.
645
+
646
+ ```
647
+ import {Field} from 'alinea'
648
+
649
+ Field.image('Single image link')
650
+
651
+ Field.image.multiple('Multiple image links')
652
+ ```
653
+
654
+ ```
655
+ import {Config, Field} from 'alinea'
656
+
657
+ export default Config.type('Image field', {
658
+ fields: {
659
+ image: Field.image('Single image link'),
660
+ imageMultiple: Field.image.multiple('Multiple image links')
661
+ }
662
+ })
663
+ ```
664
+
665
+ ## Configuration
666
+
667
+ ### `max`
668
+
669
+ Limit the amount of rows in case of multiple links.
670
+
671
+ ### `fields`
672
+
673
+ Defines nested sub-fields for the original object, allowing you to attach additional structured data.
674
+
675
+ ```
676
+ import {Config, Field} from 'alinea'
677
+
678
+ Field.image('Image', {
679
+ fields: {alt: Field.text('Alt text')}
680
+ })
681
+ ```
682
+
683
+ ### Link (/docs/configuration/fields/link)
684
+ The link field can be used to create one or multiple references to other entries or external resources (like a webpage or an email address). By default, the user can choose between internal pages, external urls or uploaded files. If you want to limit to selection to just one of those options it's possible to declare the field as either an [Entry field](/docs/configuration/fields/entry), [Url field](/docs/configuration/fields/url), [File field](/docs/configuration/fields/file), or [Image field](/docs/configuration/fields/image).
685
+
686
+ ```
687
+ import {Field} from 'alinea'
688
+
689
+ Field.link('Single link')
690
+
691
+ Field.link.multiple('Multiple links')
692
+ ```
693
+
694
+ ```
695
+ import {Config, Field} from 'alinea'
696
+
697
+ export default Config.type('Link field', {
698
+ fields: {
699
+ link: Field.link('Single link'),
700
+ linkMultiple: Field.link.multiple('Multiple links')
701
+ }
702
+ })
703
+ ```
704
+
705
+ ## Configuration
706
+
707
+ ### `max`
708
+
709
+ Limit the amount of rows in case of multiple links.
710
+
711
+ ### `fields`
712
+
713
+ Defines nested sub-fields for the original object, allowing you to attach additional structured data.
714
+
715
+ ```
716
+ import {Config, Field} from 'alinea'
717
+
718
+ Field.link('Link', {
719
+ fields: {label: Field.text('Link label')}
720
+ })
721
+ ```
722
+
723
+ ### List (/docs/configuration/fields/list)
724
+ A list field contains blocks of fields. Every block is configured using a specific type. These can be created using the schema and type functions as seen before.
725
+
726
+ ```
727
+ import {Config, Field} from 'alinea'
728
+
729
+ Field.list('List', {
730
+ schema: {
731
+ Item: Config.type('Item', {
732
+ fields: {
733
+ title: Field.text('Title'),
734
+ text: Field.richText('Text')
735
+ }
736
+ })
737
+ }
738
+ })
739
+
740
+ Field.list('List mixed', {
741
+ schema: {
742
+ Text: Config.type('Text', {
743
+ fields: {
744
+ title: Field.text('Item title'),
745
+ text: Field.richText('Item body text')
746
+ }
747
+ }),
748
+ Image: Config.type('Image', {
749
+ fields: {
750
+ image: Field.image('Image')
751
+ }
752
+ })
753
+ }
754
+ })
755
+ ```
756
+
757
+ ```
758
+ import {Config, Field} from 'alinea'
759
+
760
+ export default Config.type("List field", {
761
+ fields: {
762
+ list: Field.list("List", {
763
+ schema: {
764
+ Item: Config.type("Item", {
765
+ fields: {
766
+ title: Field.text("Title"),
767
+ text: Field.richText("Text"),
768
+ }
769
+ })
770
+ }
771
+ })
772
+ listMixed: Field.list("List mixed", {
773
+ schema: {
774
+ Text: Config.type("Text", {
775
+ fields: {
776
+ title: Field.text("Title"),
777
+ text: Field.richText("Text"),
778
+ }
779
+ })
780
+ Image: Config.type("Image", {
781
+ fields: {
782
+ image: Field.image("Image")
783
+ }
784
+ })
785
+ }
786
+ })
787
+ }
788
+ })
789
+ ```
790
+
791
+ ### Number (/docs/configuration/fields/number)
792
+ A number field is used to input numeric data.
793
+
794
+ ```
795
+ import {Field} from 'alinea'
796
+
797
+ Field.number('My number field', {
798
+ minValue: 0,
799
+ maxValue: 10,
800
+ step: 1
801
+ })
802
+ ```
803
+
804
+ ```
805
+ import {Field} from 'alinea'
806
+
807
+ export default Field.number('My number field', {
808
+ minValue: 0,
809
+ maxValue: 10,
810
+ step: 1
811
+ })
812
+ ```
813
+
814
+ ## Configuration
815
+
816
+ ### `step`
817
+
818
+ Specifies the interval between legal numbers in the input field.
819
+ Default is 1.
820
+
821
+ ```
822
+ import {Field} from 'alinea'
823
+
824
+ Field.number('My decimal number field', {
825
+ step: 0.01
826
+ })
827
+ ```
828
+
829
+ ```
830
+ import {Field} from 'alinea'
831
+
832
+ export default Field.number('My decimal number field', {
833
+ help: 'You can increase or decrease the value by 0.01',
834
+ initialValue: 0.01,
835
+ step: 0.01
836
+ })
837
+ ```
838
+
839
+ ### Object (/docs/configuration/fields/object)
840
+ An object field groups multiple fields together. The fields are defined using the type function.
841
+
842
+ ```
843
+ import {Config, Field} from 'alinea'
844
+
845
+ Field.object('Address', {
846
+ fields: {
847
+ street: Field.text('Street'),
848
+ zip: Field.text('Zip code', {width: 0.5}),
849
+ city: Field.text('City', {width: 0.5})
850
+ })
851
+ })
852
+ ```
853
+
854
+ ```
855
+ import {Config, Field} from 'alinea'
856
+
857
+ export default Config.type('Object field', {
858
+ fields: {
859
+ object: Field.object('Address', {
860
+ fields: {
861
+ street: Field.text('Street'),
862
+ zip: Field.text('Zip code', {width: 0.5}),
863
+ city: Field.text('City', {width: 0.5})
864
+ }
865
+ })
866
+ }
867
+ })
868
+ ```
869
+
870
+ ### Path (/docs/configuration/fields/path)
871
+ A path field is used to generate a slug based on another field - by default, the title field.
872
+
873
+ ```
874
+ import {Field} from 'alinea'
875
+
876
+ Field.text('Title', {required: true, width: 0.5}),
877
+ Field.path('Path', {required: true, width: 0.5})
878
+ ```
879
+
880
+ ```
881
+ import {Config, Field} from 'alinea'
882
+
883
+ export default Config.type('Path field', {
884
+ fields: {
885
+ title: Field.text('Title', {
886
+ initialValue: 'Contentpage'
887
+ required: true, width: 0.5
888
+ }),
889
+ path: Field.path('Path', {
890
+ required: true, width: 0.5
891
+ })
892
+ }
893
+ })
894
+ ```
895
+
896
+ ## Configuration
897
+
898
+ ### `from`
899
+
900
+ Automatically generate (slugify) the path from another field.
901
+
902
+ ```
903
+ import {Config, Field} from 'alinea'
904
+
905
+ export default Config.type('Path field (from)', {
906
+ fields: {
907
+ anchorId: Field.text('anchorId', {
908
+ initialValue: 'Anchord ID',
909
+ required: true, width: 0.5
910
+ }),
911
+ pathFrom: Field.path('Path from anchorId)', {
912
+ from: 'anchorId',
913
+ required: true, width: 0.5
914
+ })
915
+ }
916
+ })
917
+ ```
918
+
919
+ ```
920
+ import {Config, Field} from 'alinea'
921
+
922
+ export default Config.type('Path field (from)', {
923
+ fields: {
924
+ anchorId: Field.text('anchorId', {
925
+ initialValue: 'Anchord ID'
926
+ required: true, width: 0.5,
927
+ }),
928
+ pathFrom: Field.path('Path from anchorId)', {
929
+ from: 'anchorId',
930
+ required: true, width: 0.5
931
+ })
932
+ }
933
+ })
934
+ ```
935
+
936
+ ### `initialValue`
937
+
938
+ Prefills the path value. This is useful for cases like setting the homepage path.
939
+
940
+ ```
941
+ import {Config, Field} from 'alinea'
942
+
943
+ export default Config.type('Path field (homepage)', {
944
+ fields: {
945
+ title: Field.text('Title', {
946
+ initialValue: 'Homepage',
947
+ required: true, width: 0.5
948
+ }),
949
+ path: Field.path('Path', {
950
+ initialValue: 'index',
951
+ hidden: false,
952
+ readOnly: true,
953
+ required: true, width: 0.5
954
+ })
955
+ }
956
+ })
957
+ ```
958
+
959
+ ```
960
+ import {Config, Field} from 'alinea'
961
+
962
+ export default Config.type('Path field (homepage)', {
963
+ fields: {
964
+ title: Field.text('Title', {
965
+ initialValue: 'Homepage',
966
+ required: true, width: 0.5
967
+ }),
968
+ path: Field.path('Path', {
969
+ initialValue: 'index',
970
+ hidden: false,
971
+ readOnly: true,
972
+ required: true, width: 0.5
973
+ })
974
+ }
975
+ })
976
+ ```
977
+
978
+ ### Rich Text (/docs/configuration/fields/rich-text)
979
+ Rich text can contain text marks like bold, italics or underline. Content can be structured using headings. It can even contain other types as blocks that can be moved around freely.
980
+
981
+ ```
982
+ import {Field} from 'alinea'
983
+
984
+ Field.richText('Rich Text')
985
+ Field.richText('Extended with inline schema(s)', {
986
+ schema: {
987
+ ImageBlock
988
+ }
989
+ })
990
+ const ImageBlock = Config.type('Image', {
991
+ fields: {image: Field.image('Image', {inline: true})}
992
+ })
993
+ ```
994
+
995
+ ```
996
+ import {Config, Field} from 'alinea'
997
+
998
+ const ImageBlock = Config.type('Image', {
999
+ fields: {image: Field.image('Image', {inline: true})}
1000
+ })
1001
+
1002
+ export default Config.type('Rich Text field', {
1003
+ fields: {
1004
+ basic: Field.richText('Rich Text', {
1005
+ initialValue: [
1006
+ {_type: 'heading', level: 1, content: [
1007
+ {_type: 'text', text: "Hello world"}
1008
+ ]},
1009
+ {_type: 'paragraph', content: [
1010
+ {_type: 'text', text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit."}
1011
+ ]}
1012
+ ]
1013
+ }),
1014
+ richText: Field.richText('Extended with inline schema(s)', {
1015
+ schema: {
1016
+ ImageBlock
1017
+ },
1018
+ initialValue: [
1019
+ {_type: 'paragraph', content: [
1020
+ {_type: 'text', text: "The “Insert block” option appears when you press Enter to create a new line."}
1021
+ ]}
1022
+ ]
1023
+ })
1024
+ }
1025
+ })
1026
+ ```
1027
+
1028
+ ## Configuration
1029
+
1030
+ ### `schema`
1031
+
1032
+ Allow Types of this Schema to be created between text fragments.
1033
+
1034
+ ### `searchable`
1035
+
1036
+ Index the content of this field so it can be found in a search query.
1037
+
1038
+ ### `enableTables`
1039
+
1040
+ Allow tables to be inserted in this field.
1041
+
1042
+ ## Rendering rich text
1043
+
1044
+ Rich text values are encoded in an array.
1045
+
1046
+ Variant: JSON
1047
+ ```tsx
1048
+ [
1049
+ {
1050
+ "_type": "heading",
1051
+ "level": 1,
1052
+ "content": [
1053
+ {
1054
+ "type": "text",
1055
+ "text": "Hello world"
1056
+ }
1057
+ ]
1058
+ },
1059
+ {
1060
+ "_type": "paragraph",
1061
+ "content": [
1062
+ {
1063
+ "type": "text",
1064
+ "text": "A paragraph follows"
1065
+ }
1066
+ ]
1067
+ }
1068
+ ]
1069
+ ```
1070
+
1071
+ Variant: Types
1072
+ ```tsx
1073
+ type TextDoc = Array<TextNode>
1074
+
1075
+ type TextNode =
1076
+ | {
1077
+ _type: 'text'
1078
+ text?: string
1079
+ marks?: Array<{
1080
+ type: string
1081
+ attrs?: Record<string, string>
1082
+ }>
1083
+ }
1084
+ | {
1085
+ _type: string
1086
+ content?: TextDoc
1087
+ [key: string]: any
1088
+ }
1089
+ ```
1090
+
1091
+ Alinea provides a React component to render this array in your app. By default it will use plain tags such as h1, h2, p, ul, li, etc. to represent the text. Any of these can be customized by either passing a React component or a vnode, of which we'll copy the type and props.
1092
+
1093
+ ```
1094
+ import {RichText} from 'alinea/ui'
1095
+
1096
+ <RichText
1097
+ doc={richTextValue}
1098
+
1099
+ // Everything below is optional
1100
+
1101
+ // Render plain text with a custom component
1102
+ text={TextComponent}
1103
+
1104
+ // Attach a custom classname to paragraphs
1105
+ p={<p className="my-p" />}
1106
+
1107
+ // Use a custom component for h1 headings
1108
+ h1={MyH1Heading}
1109
+
1110
+ // Use a custom component for links
1111
+ a={LinkComponent}
1112
+
1113
+ // Attach classes to list items
1114
+ ul={<ul className="my-list" />}
1115
+ ol={<ol className="my-ordered-list" />}
1116
+ li={<li className="my-list-item" />}
1117
+
1118
+ // More options
1119
+ // b={<b />}
1120
+ // i={<i />}
1121
+ // u={<u />}
1122
+ // hr={<hr />}
1123
+ // br={<br />}
1124
+ // small={<small />}
1125
+ // blockquote={<blockquote />}
1126
+ />
1127
+ ```
1128
+
1129
+ The same principle is applied for custom blocks.
1130
+
1131
+ ```
1132
+ import {RichText} from 'alinea/ui'
1133
+ import {MyBlock} from './MyBlock'
1134
+ import {MyBlockSchema} from './MyBlock.schema'
1135
+
1136
+ const MyBlockSchema = alinea.type('Custom block', {
1137
+ property: alinea.text('Property')
1138
+ })
1139
+
1140
+ function MyBlock({property}) {
1141
+ return <span>{property}</span>
1142
+ }
1143
+
1144
+ // Add to config
1145
+ alinea.richText('My rich text field', {
1146
+ schema: alinea.schema({
1147
+ MyBlock: MyBlockSchema
1148
+ })
1149
+ })
1150
+
1151
+ // Render in page views
1152
+ <RichText
1153
+ doc={richTextValue}
1154
+
1155
+ // Render instances of my custom block with the MyBlock view
1156
+ MyBlock={MyBlock}
1157
+ />
1158
+ ```
1159
+
1160
+ If you need the HTML as string output and not as a component, you can convert the component as follows:
1161
+
1162
+ ```typescript
1163
+ import {RichText} from 'alinea/ui'
1164
+ const {renderToString} = await import('react-dom/server')
1165
+
1166
+ const html = renderToString(<RichText doc={richTextValue} />)
1167
+ ```
1168
+
1169
+ Note the dynamic import of react-dom/server. This is necessary to avoid warnings from Next.js for using server imports.
1170
+
1171
+ ### Select (/docs/configuration/fields/select)
1172
+ A select field limits data to a specific set of options.
1173
+
1174
+ ```
1175
+ import {Field} from 'alinea'
1176
+
1177
+ Field.select('My select field', {
1178
+ options: {
1179
+ a: 'Option a',
1180
+ b: 'Option b'
1181
+ }
1182
+ })
1183
+ ```
1184
+
1185
+ ```
1186
+ import {Config, Field} from 'alinea'
1187
+
1188
+ export default Config.type('Select field', {
1189
+ fields: {
1190
+ select: Field.select('My select field', {
1191
+ options: {
1192
+ a: 'Option a',
1193
+ b: 'Option b'
1194
+ }
1195
+ })
1196
+ }
1197
+ })
1198
+ ```
1199
+
1200
+ ### Tabs (/docs/configuration/fields/tabs)
1201
+ Tabs help you structure the input fields in the admin panel. Tabs do not hold data, but can declare underlying fields which do (or hold more tabs).
1202
+
1203
+ ```
1204
+ import {Field} from 'alinea'
1205
+
1206
+ Field.tabs(
1207
+ Field.tab('Tab A', {
1208
+ fields: {
1209
+ // ... fields
1210
+ }
1211
+ }),
1212
+ Field.tab('Tab B', {
1213
+ fields: {
1214
+ // ... fields
1215
+ }
1216
+ })
1217
+ )
1218
+ ```
1219
+
1220
+ ```
1221
+ import {Config, Field} from "alinea"
1222
+
1223
+ export default Config.type("Tabs field", {
1224
+ fields: {
1225
+ ...Field.tabs(
1226
+ Field.tab("Tab A", {
1227
+ fields: {
1228
+ fieldA: Field.text("Field in Tab A")
1229
+ }
1230
+ }),
1231
+ Field.tab("Tab B", {
1232
+ fields: {
1233
+ fieldB: Field.text("Field in Tab B")
1234
+ }
1235
+ })
1236
+ )
1237
+ }
1238
+ })
1239
+ ```
1240
+
1241
+ ### Text (/docs/configuration/fields/text)
1242
+ A text field is used to input textual data.
1243
+
1244
+ ```
1245
+ import {Field} from 'alinea'
1246
+
1247
+ Field.text('My text field', {
1248
+ help: 'This is an example field',
1249
+ multiline: true
1250
+ })
1251
+ ```
1252
+
1253
+ ```
1254
+ import {Config, Field} from 'alinea'
1255
+
1256
+ export default Config.type('Text field', {
1257
+ fields: {
1258
+ text: Field.text('My text field', {
1259
+ help: 'This is an example field',
1260
+ multiline: true,
1261
+ initialValue: 'A text value'
1262
+ })
1263
+ }
1264
+ })
1265
+ ```
1266
+
1267
+ ## Configuration
1268
+
1269
+ ### `searchable`
1270
+
1271
+ Index the content of this field so it can be found in a search query.
1272
+
1273
+ ### `autoFocus`
1274
+
1275
+ Focus this input automatically
1276
+
1277
+ ### Url (/docs/configuration/fields/url)
1278
+ The Url field can be used to link to an external resource.
1279
+
1280
+ ```
1281
+ import {Field} from 'alinea'
1282
+
1283
+ Field.url('Single URL link')
1284
+
1285
+ Field.url.multiple('Multiple URL links')
1286
+ ```
1287
+
1288
+ ```
1289
+ import {Config, Field} from 'alinea'
1290
+
1291
+ export default Config.type('Url field', {
1292
+ fields: {
1293
+ url: Field.url('Single URL link'),
1294
+ urlMultiple: Field.url.multiple('Multiple URL links')
1295
+ }
1296
+ })
1297
+ ```
1298
+
1299
+ ## Configuration
1300
+
1301
+ ### `max`
1302
+
1303
+ Limit the amount of rows in case of multiple links.
1304
+
1305
+ ### Roles & Permissions (/docs/configuration/roles-permissions)
1306
+ Roles define what users can see and do within the CMS dashboard. Alinea automatically creates a default Admin role for every project with full access to every workspace, root, and entry. You do not need to define this manually, ensuring there is always an unrestricted management account available.
1307
+
1308
+ ## Setting up roles
1309
+
1310
+ To create a custom role, use the Config.role helper and register it in your CMS configuration.
1311
+
1312
+ ```
1313
+ const myRole = Config.role('My role', {
1314
+ async permissions(policy, graph) {
1315
+ // Define permissions here
1316
+ }
1317
+ })
1318
+
1319
+ export const cms = createCMS({
1320
+ roles: { myRole },
1321
+ // ...other config
1322
+ })
1323
+ ```
1324
+
1325
+ ## Defining Policies
1326
+
1327
+ The permissions function is where you define your access policies. You use allow to grant access to specific actions and deny to strictly forbid them.
1328
+
1329
+ Note (info): Note: A deny rule is absolute and propagates downstream without exception. Once an action is denied at a higher level, it becomes "locked" for all child entries and cannot be overridden by an allow rule or contained by grant: 'explicit' further down the stream. Always apply deny restrictions at the most specific level possible to avoid unintentionally locking out sub-sections.
1330
+
1331
+ ### Available Actions
1332
+
1333
+ When configuring a policy, you can target these specific actions to create granular permissions:
1334
+
1335
+ read: View the entry in the dashboard.
1336
+
1337
+ create: Create new entries of a specific type.
1338
+
1339
+ update: Edit existing content.
1340
+
1341
+ delete: Remove entries from the CMS.
1342
+
1343
+ publish: Push changes to the live environment.
1344
+
1345
+ archive: Move entries to an archived state.
1346
+
1347
+ reorder: Change the sorting order within a parent.
1348
+
1349
+ move: Relocate entries to a different parent or root.
1350
+
1351
+ upload: Add new media files to the library.
1352
+
1353
+ explore: Browse through the workspace structure.
1354
+
1355
+ all: A shorthand to grant every possible permission at once.
1356
+
1357
+ ## Understanding Grants
1358
+
1359
+ The grant property specifically modifies how allow rules are applied across the content tree. By default, permissions use grant: 'implicit', meaning access trickles down to all children.
1360
+
1361
+ ```
1362
+ // (Default) Permissions automatically trickle down to all child entries and sub-pages.
1363
+ grant: 'implicit'
1364
+ // Permissions only apply to the specific entry or level defined.
1365
+ // Access to children must be granted separately.
1366
+ grant: 'explicit'
1367
+ ```
1368
+
1369
+ ## Example use cases
1370
+
1371
+ ### Static Access Control
1372
+
1373
+ In these examples, we define roles based on fixed paths or roots within your CMS. This is the most direct way to manage access for different user groups.
1374
+
1375
+ Viewer: This role is granted read-only access to the entire workspace by default but is specifically excluded from sensitive administrative roots such as configurations and databases.
1376
+
1377
+ ```
1378
+ // Block specified pages, only give read-rights
1379
+ const viewer = Config.role('Viewer', {
1380
+ async permissions(policy) {
1381
+ policy.set(
1382
+ {
1383
+ allow: {read: true}
1384
+ },
1385
+ {
1386
+ root: cms.workspaces.main.general,
1387
+ deny: {read: true}
1388
+ },
1389
+ {
1390
+ root: cms.workspaces.main.redirects,
1391
+ deny: {read: true}
1392
+ },
1393
+ {
1394
+ root: cms.workspaces.main.configurator,
1395
+ deny: {read: true}
1396
+ },
1397
+ {
1398
+ root: cms.workspaces.main.database,
1399
+ deny: {read: true}
1400
+ }
1401
+ )
1402
+ }
1403
+ })
1404
+ ```
1405
+
1406
+ Editor: Similar to the Viewer, but with the addition of editing rights (update) for all accessible sections.
1407
+
1408
+ ```
1409
+ // Block acces to certain roots, give read and update rights to everything else
1410
+ const editor = Config.role('Editor', {
1411
+ async permissions(policy) {
1412
+ policy.set(
1413
+ {
1414
+ allow: {read: true, update: true}
1415
+ },
1416
+ {
1417
+ root: cms.workspaces.main.general,
1418
+ deny: {read: true}
1419
+ },
1420
+ {
1421
+ root: cms.workspaces.main.redirects,
1422
+ deny: {read: true}
1423
+ },
1424
+ {
1425
+ root: cms.workspaces.main.configurator,
1426
+ deny: {read: true}
1427
+ },
1428
+ {
1429
+ root: cms.workspaces.main.database,
1430
+ deny: {read: true}
1431
+ }
1432
+ )
1433
+ }
1434
+ })
1435
+ ```
1436
+
1437
+ ### Explicit Granular Access
1438
+
1439
+ Sometimes you want to grant a user access to a specific item without immediately making the entire underlying tree structure visible. By using grant: 'explicit', you ensure that rights must be manually assigned at each level. This is ideal for moderators who only need to manage one specific page (like a particular bike) while keeping the rest of the navigation hidden.
1440
+
1441
+ ```
1442
+ const bikeId = '4d454f57636d735f323032365f7a'
1443
+
1444
+ // Grant explicit to make sure not all children are visible
1445
+ const bikeModerator = Config.role('Bike Moderator', {
1446
+ async permissions(policy) {
1447
+ policy.set(
1448
+ {
1449
+ workspace: cms.workspaces.main,
1450
+ allow: {read: true},
1451
+ grant: 'explicit'
1452
+ },
1453
+ {
1454
+ root: cms.workspaces.main.pages,
1455
+ allow: {read: true},
1456
+ grant: 'explicit'
1457
+ },
1458
+ {
1459
+ id: bikeId,
1460
+ allow: {all: true}
1461
+ }
1462
+ )
1463
+ }
1464
+ })
1465
+ ```
1466
+
1467
+ ### Dynamic Query-based Permissions
1468
+
1469
+ Alinea offers the unique capability to base permissions on your data structure via the graph. Instead of hardcoding IDs, you can perform a query to retrieve, for example, all products belonging to a specific brand. The results of this query are then used to dynamically assign permissions. This means you don't have to update the role when new products are added to the brand; the permissions automatically grow along with your content.
1470
+
1471
+ ```
1472
+ bikeBrandId = '2o7I2ig8ry0ci1e0hZ5k1kKK3Qx'
1473
+
1474
+ // Use querying to show only specific products
1475
+ const bikeBrandModerator = Config.role('Bike Brand Moderator', {
1476
+ async permissions(policy, graph) {
1477
+ const bikes = await graph.find({
1478
+ type: ProductSchema,
1479
+ filter: {
1480
+ brand: {
1481
+ has: {_entry: bikeBrandId}
1482
+ }
1483
+ }
1484
+ })
1485
+ policy.set({
1486
+ workspace: cms.workspaces.main,
1487
+ allow: {read: true},
1488
+ grant: 'explicit'
1489
+ },
1490
+ {
1491
+ root: cms.workspaces.main.products,
1492
+ allow: {read: true},
1493
+ grant: 'explicit'
1494
+ })
1495
+ for (const bike of bikes) {
1496
+ policy.set({
1497
+ id: bike._id,
1498
+ allow: {read: true, update: true}
1499
+ })
1500
+ }
1501
+ }
1502
+ })
1503
+ ```
1504
+
1505
+ ### Schema (/docs/configuration/schema)
1506
+ A schema is a collection of [Types](/docs/configuration/schema/type).
1507
+
1508
+ ```
1509
+ import {Config} from 'alinea'
1510
+
1511
+ Config.schema({
1512
+ types: {
1513
+ TypeA, TypeB, TypeC
1514
+ }
1515
+ })
1516
+ ```
1517
+
1518
+ ## Configuration
1519
+
1520
+ Your Schema should be defined in the [CMS config](/docs/configuration). There are currently no configuration options for it.
1521
+
1522
+ ## Example schema
1523
+
1524
+ The Schema below is a minimal example of a blog setup. It holds two types: `BlogOverview` and `BlogPost`. The overview type corresponds to a page that lists the posts. To achieve that it is configured as a container which can hold blog posts as children.
1525
+
1526
+ ```
1527
+ import {Config, Field} from 'alinea'
1528
+
1529
+ Config.schema({
1530
+ types: {
1531
+ BlogOverview: Config.document('Blog overview', {
1532
+ contains: ['BlogPost']
1533
+ }),
1534
+ BlogPost: Config.document('Blog post', {
1535
+ fields: {
1536
+ publishDate: Field.date('Publish date'),
1537
+ body: Field.richText('Body')
1538
+ }
1539
+ })
1540
+ }
1541
+ })
1542
+ ```
1543
+
1544
+ ### Document (/docs/configuration/schema/document)
1545
+ Use the `document` utility function to create a [Type](/docs/configuration/schema/type) with the following preconfigured fields:
1546
+
1547
+ - `title`
1548
+ - `path`
1549
+ - `metadata`
1550
+
1551
+ ```
1552
+ import {Config} from 'alinea'
1553
+
1554
+ Config.document('Web page', {
1555
+ fields: {
1556
+ // Extra fields
1557
+ }
1558
+ })
1559
+ ```
1560
+
1561
+ ## Configuration
1562
+
1563
+ Configuration options are the same as for a [Type](/docs/configuration/schema/type).
1564
+
1565
+ ### Type (/docs/configuration/schema/type)
1566
+ A type defines fields and presentational settings. Fields hold data that can be edited in the dashboard. Top level types which are used in a [Schema](/docs/configuration/schema)
1567
+
1568
+ ```
1569
+ import {Config, Field} from 'alinea'
1570
+
1571
+ Config.type('Blog', {
1572
+ contains: ['BlogPost'],
1573
+ fields: {
1574
+ title: Field.text('Title', {required: true, width: 0.5}),
1575
+ path: Field.path('Path', {required: true, width: 0.5})
1576
+ }
1577
+ })
1578
+ ```
1579
+
1580
+ ## Configuration
1581
+
1582
+ Types should be defined within a [Schema](/docs/configuration/schema)
1583
+
1584
+ ### `contains`
1585
+
1586
+ Accept entries of these Types as children. For example a `Blog` can accept `BlogEntry` children.
1587
+
1588
+ ```
1589
+ contains: ['BlogEntry']
1590
+ ```
1591
+
1592
+ ### `orderChildrenBy`
1593
+
1594
+ Defines the sort order of child entries, either ascending or descending, based on a chosen field.
1595
+
1596
+ ```
1597
+ orderChildrenBy: {asc: PageSchema.title}
1598
+ orderChildrenBy: {desc: NewsDetailSchema.date}
1599
+ ```
1600
+
1601
+ ### `entryUrl`
1602
+
1603
+ The `url` property of entries can be controlled using the `entryUrl` function in the type options. Urls are computed during generation and this can help to keep them constant if you're using a web framework that does file system routing. The available parameters are `path`, `parentPaths` and `locale`. For example: making sure a doc page always has an url in the form of `/doc/$path` you can specify `entryUrl` as the following:
1604
+
1605
+ ```
1606
+ entryUrl({path}) {
1607
+ return `/doc/${path}`
1608
+ }
1609
+ ```
1610
+
1611
+ ### `icon`
1612
+
1613
+ An icon can be used to label a type in the sidebar entry tree. Icons are implemented as a React component. You can find icons on [Icones](https://icones.js.org)
1614
+
1615
+ ## Good to know
1616
+
1617
+ ### Fields must be unique
1618
+
1619
+ Alinea will throw an error if a field shows up multiple times in the same `Type`. Having unique fields is important so we can use the field references directly when [querying data](/docs/content/query)
1620
+
1621
+ ```
1622
+ import {Config, Field} from 'alinea'
1623
+
1624
+ const textField = Field.text('Text field')
1625
+ const MyType = Config.type('My type', {
1626
+ fields: {
1627
+ fieldA: textField,
1628
+ fieldB: textField // This is not allowed
1629
+ }
1630
+ })
1631
+
1632
+ // You can rewrite the above to get unique
1633
+ // references by making textField a function
1634
+ const textField = () => Field.text('Text field')
1635
+ const MyType = Config.type('My type', {
1636
+ fields: {
1637
+ fieldA: textField(),
1638
+ fieldB: textField() // This is fine
1639
+ }
1640
+ })
1641
+ ```
1642
+
1643
+ ### Workspaces (/docs/configuration/workspaces)
1644
+ Workspaces allow you to structure content into logical categories. For example it is possible to power multiple websites with a single CMS dashboard by using a workspace per website. Editors can easily switch workspaces in the dashboard.
1645
+
1646
+ ```
1647
+ import {Config} from 'alinea'
1648
+
1649
+ Config.workspace('Main workspace', {
1650
+ color: '#3F61E8',
1651
+ source: 'content',
1652
+ mediaDir: 'public',
1653
+ roots: {
1654
+ // Website pages can be managed within this Root
1655
+ pages: Config.root('Pages'),
1656
+ // Image and file uploads will end up here
1657
+ media: Config.media()
1658
+ }
1659
+ })
1660
+ ```
1661
+
1662
+ ## Configuration
1663
+
1664
+ Workspaces should be defined in your [CMS config](/docs/configuration).
1665
+
1666
+ ### `color`
1667
+
1668
+ Pick a theme color this workspace.
1669
+
1670
+ ### `source`
1671
+
1672
+ A directory in which published content is stored.
1673
+
1674
+ ### `roots`
1675
+
1676
+ An object containing [Roots](/docs/configuration/workspaces/root).
1677
+
1678
+ ```
1679
+ // content is stored in the `content` directory
1680
+ source: './content'
1681
+ ```
1682
+
1683
+ ### `mediaDir`
1684
+
1685
+ A directory in which uploaded files are placed. In case you're using Alinea to manage web content this will often point to a directory that is made publicly available so an url can be created to download or display the file.
1686
+
1687
+ ```
1688
+ // uploaded files are placed in the `public` folder
1689
+ mediaDir: './public'
1690
+ ```
1691
+
1692
+ ### Media (/docs/configuration/workspaces/media)
1693
+ To manage uploads a Media Root is added to a Workspace. Multiple Media Roots are supported but uploads will default to the first Media Root found.
1694
+
1695
+ ```
1696
+ import {Config} from 'alinea'
1697
+
1698
+ Config.workspace('Main workspace', {
1699
+ roots: {
1700
+ // ...
1701
+ media: Config.media()
1702
+ }
1703
+ })
1704
+ ```
1705
+
1706
+ ## Configuration
1707
+
1708
+ Media Roots should be defined within a [Workspace](/docs/configuration/workspaces).
1709
+
1710
+ ### Roots (/docs/configuration/workspaces/root)
1711
+ Roots help you organize content hierarchically in a tree. Pages can have subpages, subpages can have sub-subpages and so on. This hierarchy starts at a `Root`.
1712
+
1713
+ ```
1714
+ import {Config} from 'alinea'
1715
+
1716
+ Config.root('Pages', {
1717
+ i18n: {
1718
+ locales: ['en', 'fr', 'nl']
1719
+ },
1720
+ preview: true,
1721
+ contains: ['HomePage', 'GenericPage'],
1722
+ children: {
1723
+ index: Config.page({
1724
+ type: HomePage,
1725
+ fields: {title: 'Home'}
1726
+ }),
1727
+ 'about-us': Config.page({
1728
+ type: GenericPage,
1729
+ fields: {title: 'About us'}
1730
+ })
1731
+ }
1732
+ })
1733
+ ```
1734
+
1735
+ ## Configuration
1736
+
1737
+ Roots should be defined within a [Workspace](/docs/configuration/workspaces).
1738
+
1739
+ ### `i18n`
1740
+
1741
+ An object with internationalization configuration. Locales is an array of strings, containing locales described by a key that will, by default, show up in the url structure as well.
1742
+
1743
+ ```
1744
+ i18n: {
1745
+ locales: ['en', 'fr', 'nl']
1746
+ }
1747
+ ```
1748
+
1749
+ ### `icon`
1750
+
1751
+ An icon can be used to label a root in the sidebar. Icons are implemented as a React component. You can find icons on [Icones](https://icones.js.org) or install a package such as [react-icons](https://react-icons.github.io/react-icons).
1752
+
1753
+ ### `preview`
1754
+
1755
+ Display an iframe with live previews on the side of the editor.
1756
+
1757
+ ### `contains`
1758
+
1759
+ An array of strings containing the names of types that can be created at the root.
1760
+
1761
+ ```
1762
+ // only entries of type Page can be created at the top of the tree
1763
+ contains: ['GenericPage']
1764
+ ```
1765
+
1766
+ ### `children`
1767
+
1768
+ Defines child entries that can be automatically seeded with predefined types and fields.
1769
+ The first object sets the path field. To create an empty path (for a homepage), set the path to index.
1770
+
1771
+ ```
1772
+ children: {
1773
+ index: Config.page({
1774
+ type: HomePage,
1775
+ fields: {title: 'Home'}
1776
+ })
1777
+ }
1778
+ ```
1779
+
1780
+ ### Content (/docs/content)
1781
+ Alinea generates files on your file system in a few places.
1782
+
1783
+ - Content is published into a directory as JSON files. Note that [we're considering](https://github.com/alineacms/alinea/issues/10) making these files more human editable in the future too.
1784
+ - Media files are published in two places: a JSON file with metadata is placed with the content and the file itself is placed in a separate directory, typically `./public`.
1785
+ - A supporting Javascript library is generated inside the `@alinea/generated` package.
1786
+
1787
+ ```
1788
+ // Published content is stored in json files
1789
+ // inside the content directory
1790
+ content
1791
+ ├ pages // pages root
1792
+ │ ├ index.json
1793
+ │ ├ blog.json
1794
+ │ ╰ blog
1795
+ │ ├ blog-post.json
1796
+ │ ╰ another-blog-post.json
1797
+ ╰ media // media root
1798
+ ├ image.json
1799
+ ╰ file.json
1800
+
1801
+ // A folder which is publicly available
1802
+ public
1803
+ ├ image.XYZ.png
1804
+ ╰ file.XYZ.pdf
1805
+
1806
+ // The alinea directory exports a Javascript library
1807
+ // which can be imported from @alinea/generated
1808
+ node_modules/@alinea/generated
1809
+ ├ ...
1810
+ ├ config.js
1811
+ ╰ source.js
1812
+ ```
1813
+
1814
+ ### Editing content (/docs/content/editing-content)
1815
+ Your CMS instance has methods to create and update content. During development these will update the file system directly, while in production the changes will result in a new git commit.
1816
+
1817
+ ## Creating Entries
1818
+
1819
+ New Entries can be created using the `create` function.
1820
+
1821
+ ```
1822
+ import {Edit} from 'alinea'
1823
+
1824
+ // Start a transaction to create a new entry of type BlogPost
1825
+ const post = Edit.create({
1826
+ type: BlogPost,
1827
+ set: {
1828
+ title: 'A new blog post',
1829
+ body: 'Hello world'
1830
+ }
1831
+ })
1832
+
1833
+ // The new entry ID can be read before comitting
1834
+ console.log(`Creating post with id: ${post.id}`)
1835
+
1836
+ // Save the changes
1837
+ await cms.commit(post)
1838
+ ```
1839
+
1840
+ ### Creating children
1841
+
1842
+ Set the `_parentId` property to nest children under a parent.
1843
+
1844
+ ```
1845
+ import {Edit} from 'alinea'
1846
+
1847
+ const blog = Edit.create({
1848
+ type: Blog,
1849
+ set: {title: 'Blog'}
1850
+ })
1851
+ await cms.commit(blog)
1852
+
1853
+ const posts = postData.map(data =>
1854
+ Edit.create({
1855
+ type: BlogPost,
1856
+ parentId: blog.id,
1857
+ set: {
1858
+ title: data.title
1859
+ }
1860
+ })
1861
+ )
1862
+ await cms.commit(...posts)
1863
+ ```
1864
+
1865
+ ## Update Fields
1866
+
1867
+ Entry fields can be edited using the `update` function. Optionally pass the entry Type if you want to update its fields.
1868
+
1869
+ ```
1870
+ import {Edit} from 'alinea'
1871
+
1872
+ // Select the first blog post
1873
+ const post = await cms.get({
1874
+ type: BlogPost
1875
+ })
1876
+
1877
+ // Edit a field and save
1878
+ const update = Edit.update({
1879
+ id: post._id,
1880
+ type: BlogPost,
1881
+ set: {
1882
+ body: 'New body text'
1883
+ }
1884
+ })
1885
+
1886
+ await cms.commit(update)
1887
+ ```
1888
+
1889
+ ### Constructing field values
1890
+
1891
+ Some fields contain values that are more complex than a string. The Edit namespace contains helper functions to construct these. In this example we construct the value of a List Field.
1892
+
1893
+ ```
1894
+ const richTextField = richText('Item body text');
1895
+ const listField = list('My list field', {
1896
+ schema: {
1897
+ Text: type('Text', {
1898
+ title: text('Item title'),
1899
+ text: richText,
1900
+ })
1901
+ }
1902
+ })
1903
+ const rowText = Edit.richText(richTextField)
1904
+ .addHtml(`
1905
+ <h1>Main heading</h1>
1906
+ <p>A rich text value parsed from HTML.</p>
1907
+ `)
1908
+ .value()
1909
+ const listValue = Edit.list(listField)
1910
+ .add('Text', {
1911
+ title: 'The row title',
1912
+ text: rowText,
1913
+ })
1914
+ .value()
1915
+ const update = Edit.update({
1916
+ id: entryId,
1917
+ type: TypeWithList,
1918
+ set: {list: listValue}
1919
+ })
1920
+ ```
1921
+
1922
+ ## File uploads
1923
+
1924
+ Files can be uploaded using the upload function.
1925
+
1926
+ ```
1927
+ import {Edit} from 'alinea'
1928
+
1929
+ const file = new File(['content'], 'test.txt')
1930
+ const upload = Edit.upload({file})
1931
+
1932
+ // The new entry ID can be read before comitting
1933
+ console.log(`Creating post with id: ${upload.id}`)
1934
+
1935
+ // Upload file and save file metadata
1936
+ await cms.commit(upload)
1937
+ ```
1938
+
1939
+ ### Creating image previews
1940
+
1941
+ Alinea can create all the metadata for images (such as previews) by passing a `createPreview` function. On the server this will use the `sharp` package to read image data. The package will need to be installed separately.
1942
+
1943
+ ```
1944
+ import {Edit} from 'alinea'
1945
+ import {createPreview} from 'alinea/core/media/CreatePreview'
1946
+ import fs from 'node:fs'
1947
+
1948
+ const file = new File([
1949
+ fs.readFileSync('./test.png')
1950
+ ], 'test.png')
1951
+ const upload = Edit.upload({
1952
+ file, createPreview
1953
+ })
1954
+ await cms.commit(upload)
1955
+ ```
1956
+
1957
+ ### Live previews (/docs/content/live-previews)
1958
+ You can set up a live preview of your website inside the dashboard.
1959
+
1960
+ Framework: next
1961
+
1962
+ Update your [CMS config](/docs/configuration) to enable live previews:
1963
+
1964
+ File: cms.ts
1965
+ ```
1966
+ import {createCMS} from 'alinea/next'
1967
+
1968
+ export const cms = createCMS({
1969
+ // schema and workspaces ...
1970
+ baseUrl: {
1971
+ // Point this url to your Next.js website
1972
+ development: 'http://localhost:3000'
1973
+ },
1974
+ preview: true
1975
+ })
1976
+ ```
1977
+
1978
+ Include the preview widget (`<cms.previews />`) in your root layout:
1979
+
1980
+ File: app/layout.tsx
1981
+ ```
1982
+ import {cms} from '@/cms'
1983
+
1984
+ export default async function Layout({children}: PropsWithChildren) {
1985
+ return (
1986
+ <>
1987
+ <header />
1988
+ <main>{children}</main>
1989
+ <footer />
1990
+ <cms.previews widget />
1991
+ </>
1992
+ )
1993
+ }
1994
+ ```
1995
+
1996
+ This will enable a little widget at the bottom of the page that confirms to the editor they're looking at a preview of the page. If you don't want a preview widget but do want live previews, remove the `widget` prop.
1997
+
1998
+ ### Querying content (/docs/content/query)
1999
+ ## Setup
2000
+
2001
+ Once content has been saved in the CMS you'll want a way to retrieve it. Your CMS instance has methods to fetch specific entries or search through all content.
2002
+
2003
+ Framework: next
2004
+
2005
+ ```
2006
+ import {cms} from '@/cms'
2007
+
2008
+ export default async function HomePage() {
2009
+ const homePage = await cms.get({type: HomePage})
2010
+ return <h1>{homePage.title}</h1>
2011
+ }
2012
+ ```
2013
+
2014
+ ## Methods
2015
+
2016
+ There are multiple ways to retrieve your data, which one you use depends on how you want to receive it.
2017
+
2018
+ ```
2019
+ // Always returns an array
2020
+ await cms.find()
2021
+
2022
+ // Returns the first result or null
2023
+ await cms.first()
2024
+ // Returns only one result or throws an exception
2025
+ await cms.get()
2026
+
2027
+ // Returns the number of results
2028
+ await cms.count()
2029
+ ```
2030
+
2031
+ ## The Query object
2032
+
2033
+ When querying items you will have to use the Query object for its two use cases:
2034
+
2035
+ ### Referencing standard fields:
2036
+
2037
+ ```
2038
+ Query.id // The unique identifier of the entry.
2039
+ Query.title // The title of the entry.
2040
+ Query.type // The name of the entry's type (e.g., 'BlogPost').
2041
+ Query.url // The calculated public URL of the entry.
2042
+ Query.path // The path segment of the entry (e.g., 'my-post').
2043
+ Query.parentId // The ID of the parent entry.
2044
+ Query.workspace // The workspace the entry belongs to.
2045
+ Query.root // The root the entry belongs to.
2046
+ Query.locale // The locale code of the entry (e.g., 'en', 'fr').
2047
+ Query.status // The status of the entry (e.g., 'published').
2048
+ Query.index // The sort index of the entry within its parent.
2049
+ ```
2050
+
2051
+ ### Defining relationships:
2052
+
2053
+ Related (/docs/content/query/related)
2054
+
2055
+ ### Advanced (/docs/content/query/advanced)
2056
+ ## Complex Relations
2057
+
2058
+ When fetching data, you will eventually need information from a linked entry. Instead of performing multiple fetches, Alinea allows you to nest these requests within a single call.
2059
+
2060
+ ```
2061
+ await cms.first({
2062
+ locale,
2063
+ url,
2064
+ type: ParentSchema,
2065
+ select: {
2066
+ ...ParentSchema,
2067
+ // Use filters from another schema to retrieve related data
2068
+ filters: ChildSchema.filters.find({
2069
+ select: {
2070
+ id: Query.id,
2071
+ title: Query.title,
2072
+ // Fetch multiple levels of nested relations in a single, optimized query
2073
+ filterItems: Query.children({
2074
+ select: {
2075
+ id: Query.id,
2076
+ title: Query.title
2077
+ }
2078
+ })
2079
+ }
2080
+ })
2081
+ }
2082
+ })
2083
+ ```
2084
+
2085
+ ## Query Logic
2086
+
2087
+ ### OR Logic
2088
+
2089
+ By default, providing an array of values to a filter field or using the {in: [...]} operator will apply OR logic. This means the query will return any entry that matches at least one of the specified criteria. It is the most efficient way to broaden your search across multiple possible values, such as categories or tags.
2090
+
2091
+ ```
2092
+ await cms.find({
2093
+ type: ChildSchema,
2094
+ filter: {
2095
+ category: {in: ['abc', 'def']}
2096
+ }
2097
+ })
2098
+ ```
2099
+
2100
+ For more complex scenarios, such as combining filters across different fields use the explicit or operator. This allows you to group multiple distinct conditions where only one needs to be true for an entry to be included.
2101
+
2102
+ ```
2103
+ await cms.find({
2104
+ type: BlogPostSchema,
2105
+ filter: {
2106
+ or: [
2107
+ {author: 'alinea'},
2108
+ {category: 'news'}
2109
+ ]
2110
+ }
2111
+ })
2112
+ ```
2113
+
2114
+ ### AND Logic
2115
+
2116
+ Use AND logic when an entry must strictly satisfy multiple conditions simultaneously. In Alinea, this is achieved by using the and operator, which requires every filter within its scope to be true for an entry to be included in the results. This is particularly useful for complex cross-referencing where a partial match is not enough.
2117
+
2118
+ ```
2119
+ await cms.find({
2120
+ type: ChildSchema,
2121
+ filter: {
2122
+ // Use .map to create a filter condition for each ID
2123
+ and: ['abc', 'def'].map(id => ({
2124
+ filters: {includes: { _entry: id }}
2125
+ }))
2126
+ }
2127
+ })
2128
+ ```
2129
+
2130
+ ## Global Search
2131
+
2132
+ Alinea also provides a global search property. This allows you to perform broad queries across your content without targeting specific structural properties or nested filters.
2133
+
2134
+ ```
2135
+ await cms.find({
2136
+ type: BlogPostSchema,
2137
+ search: 'querying',
2138
+ })
2139
+
2140
+ //You can also pass an array into the search, it then works like a logical OR
2141
+ await cms.first({
2142
+ type: BlogPostSchema,
2143
+ search: ['cats', 'dogs'], // This will return all blogposts containing cats or dogs
2144
+ })
2145
+ ```
2146
+
2147
+ ### Examples (/docs/content/query/examples)
2148
+ ## Example Schemas
2149
+
2150
+ ```
2151
+ export const ChildSchema = Config.document('Child', {
2152
+ fields: {
2153
+ title: Field.text('Title'),
2154
+ // A multiple entry field to link related filters or categories
2155
+ filters: Field.entry.multiple('Filter(s)', {
2156
+ condition: { _type: 'FilterItem' }
2157
+ }),
2158
+ // Main content area
2159
+ blocks: Field.text('Content')
2160
+ }
2161
+ })
2162
+ ```
2163
+
2164
+ ```
2165
+ export const BlogPostSchema = Config.document('Blog Post', {
2166
+ fields: {
2167
+ path: Field.path('Path'),
2168
+ title: Field.text('Title'),
2169
+ description: Field.text('Description'),
2170
+ publishedDate: Field.date('Published Date'),
2171
+ // Used for the "Related Content" example
2172
+ tags: Field.list('Tags', {
2173
+ schema: Field.text('Tag')
2174
+ })
2175
+ }
2176
+ })
2177
+ ```
2178
+
2179
+ ## Recent Content & Pagination
2180
+
2181
+ This example shows how to retrieve the most recent entries while implementing a reusable pagination logic. By using constants for pageLength, you keep your query logic clean and easy to maintain.
2182
+
2183
+ ```
2184
+ const pageLength = 10
2185
+ const pageIndex = 0 // Start at the first page
2186
+
2187
+ await cms.find({
2188
+ type: BlogPost,
2189
+ // Sort by date to get the newest items first
2190
+ orderBy: {
2191
+ desc: BlogPost.publishedDate
2192
+ },
2193
+ // Calculate offset based on the current page
2194
+ skip: pageIndex * pageLength,
2195
+ take: pageLength
2196
+ })
2197
+ ```
2198
+
2199
+ ## Filtering by Linked References
2200
+
2201
+ ### OR Logic
2202
+
2203
+ Use this approach when you want to retrieve entries that are linked to one or more specific items. This is particularly useful for features like "Filtering by Category" or "View all posts by these Authors."
2204
+
2205
+ ```
2206
+ const filterIds = [
2207
+ 'abc123',
2208
+ 'def456'
2209
+ ]
2210
+
2211
+ await cms.find({
2212
+ type: ChildSchema,
2213
+ filter: {
2214
+ filters: {
2215
+ includes: {_entry: {in: filterIds}}
2216
+ }
2217
+ }
2218
+ })
2219
+ ```
2220
+
2221
+ ### AND Logic
2222
+
2223
+ If you need to find entries that contain all specified references simultaneously, you can map over your IDs using an and operator. This ensures every single link in your list must be present for a match.
2224
+
2225
+ ```
2226
+ const filterIds = [
2227
+ 'abc123',
2228
+ 'def456'
2229
+ ]
2230
+
2231
+ await cms.find({
2232
+ type: ChildSchema,
2233
+ filter: {
2234
+ and: filterIds.map(id => ({
2235
+ filters: {includes: {_entry: id}}
2236
+ }))
2237
+ }
2238
+ })
2239
+ ```
2240
+
2241
+ This pattern demonstrates how to find related content by comparing shared references. First, we retrieve the current entry to extract its linked metadata. Then, we use those IDs to query other entries of the same type, ensuring the current item is excluded from the results to provide a clean list of recommendations. This leverages Next.js routing params.
2242
+
2243
+ Framework: next
2244
+
2245
+ ```
2246
+ type ChildProps = {locale: Locale; url: string}
2247
+
2248
+ export default async function Child({locale, url}: ChildProps) {
2249
+ const page = await cms.first({locale, url, type: ChildSchema})
2250
+ if (!page) return
2251
+ const filterIds = page.filters.map(filter => filter?._entry).filter(Boolean)
2252
+
2253
+ const other = await cms.find({
2254
+ type: ChildSchema,
2255
+ id: {notIn: [page._id]},
2256
+ filter: {
2257
+ filters: {
2258
+ includes: {_entry: {in: filterIds}}
2259
+ }
2260
+ },
2261
+ select: {title: Query.title}
2262
+ })
2263
+ }
2264
+ ```
2265
+
2266
+ ### Filtering (/docs/content/query/filtering)
2267
+ Use the filter property when you need to narrow down your results based on dynamic criteria or specific field values. While Structural Querying defines where or what you are looking for, Filtering allows you to search based on content, such as authors, dates, or full-text search terms.
2268
+
2269
+ ```
2270
+ await cms.first({
2271
+ type: BlogPostSchema,
2272
+ // Filter using search terms
2273
+ search: 'querying',
2274
+ // Filter by fields
2275
+ // This filters the blogposts with author 'alinea'
2276
+ filter: {
2277
+ author: 'alinea'
2278
+ }
2279
+ })
2280
+ ```
2281
+
2282
+ ### Comparison Operators
2283
+
2284
+ When filtering, you can use these operators to create precise conditions. They are especially useful for dates, numbers, and strings.
2285
+
2286
+ is / isNot (Equality)
2287
+
2288
+ ```
2289
+ filter: {status:{is: 'published'}}
2290
+ filter: {type: {isNot: 'feature'}}}
2291
+ ```
2292
+
2293
+ in / notIn (List matching)
2294
+
2295
+ ```
2296
+ filter: {category: {in: ['news', 'tech']}}
2297
+ filter: {tags: {notIn: ['draft', 'internal']}}
2298
+ ```
2299
+
2300
+ gt / gte (Greater than (or equal))
2301
+
2302
+ ```
2303
+ filter: {price: {gt: 100}}
2304
+ filter: {publishedDate: {gte: '2024-01-01'}}
2305
+ ```
2306
+
2307
+ lt / lte (Less than (or equal))
2308
+
2309
+ ```
2310
+ filter: {stock: {lt: 10}}
2311
+ filter: {date: {lte: today}}
2312
+ ```
2313
+
2314
+ startsWith (String matching)
2315
+
2316
+ ```
2317
+ filter: {title: {startsWith: 'Alinea'}}
2318
+ ```
2319
+
2320
+ or (Logical OR)
2321
+
2322
+ ```
2323
+ filter: {or: [{category: 'blog'}, {featured: true}]}
2324
+ ```
2325
+
2326
+ ### Example
2327
+
2328
+ Let's take using a date as a filter for example as this lets us show quite a few of your options.
2329
+
2330
+ ```
2331
+ const today = new Date().toISOString()
2332
+
2333
+ // Getting the posts published in 2023
2334
+ await cms.find({
2335
+ type: BlogPostSchema,
2336
+
2337
+ filter: {
2338
+ author: 'alinea',
2339
+ publishedDate: {
2340
+ lt: '2024-01-01',
2341
+ gte: '2023-01-01'
2342
+ }
2343
+ }
2344
+ })
2345
+
2346
+ // Getting future events
2347
+ await cms.find({
2348
+ type: Event,
2349
+
2350
+ filter: {
2351
+ date: {gte: today}
2352
+ }
2353
+ })
2354
+ ```
2355
+
2356
+ ### Related (/docs/content/query/related)
2357
+ ## Relational Querying
2358
+
2359
+ As mentioned previously, you can use include to fetch related 'edges' of a queried item. Instead of manually storing IDs and performing multiple round-trips, Alinea provides built-in relational querying for a more seamless developer experience.
2360
+
2361
+ ```
2362
+ await cms.first({
2363
+ type: DocsSchema,
2364
+ include: {
2365
+ parents: Query.parents({
2366
+ select: {
2367
+ url: Query.url,
2368
+ title: Query.title
2369
+ }
2370
+ }),
2371
+ children: Query.children({
2372
+ type: Doc,
2373
+ select: {
2374
+ url: Query.url,
2375
+ title: Query.title
2376
+ }
2377
+ }),
2378
+ siblings: Query.siblings({
2379
+ type: Docs,
2380
+ select: {
2381
+ url: Query.url,
2382
+ title: Query.title
2383
+ }
2384
+ })
2385
+ }
2386
+ })
2387
+ ```
2388
+
2389
+ ## Sequential Navigation
2390
+
2391
+ You can also leverage previous and next helpers for sequential navigation, ideal for building components like the pagination links at the bottom of this page.
2392
+
2393
+ ```
2394
+ await cms.first({
2395
+ type: DocSchema,
2396
+
2397
+ include: {
2398
+ previous: Query.previous({
2399
+ select: {
2400
+ url: Query.url,
2401
+ title: Query.title
2402
+ }
2403
+ }),
2404
+ next: Query.next({
2405
+ select: {
2406
+ url: Query.url,
2407
+ title: Query.title
2408
+ }
2409
+ })
2410
+ }
2411
+ })
2412
+ ```
2413
+
2414
+ ## Translations
2415
+
2416
+ Fetches translations of the current entry by locale.
2417
+
2418
+ ```
2419
+ await cms.first({
2420
+ type: DocSchema,
2421
+
2422
+ include: {
2423
+ translations: Query.translations({
2424
+ select: {locale: Query.locale, url: Query.url}
2425
+ })
2426
+ }
2427
+ })
2428
+ ```
2429
+
2430
+ ### Structural (/docs/content/query/structural)
2431
+ ## Structural Querying
2432
+
2433
+ Use top-level properties to define the scope of your query. This allows you to retrieve entries based on their structural identity such as content type, unique ID or their specific location in the project tree.
2434
+
2435
+ ### 1. Using default fields
2436
+
2437
+ ```
2438
+ const pageIds = ['abc123', 'def456', 'ghi789']
2439
+ await cms.first({
2440
+ // Filter by the schema.
2441
+ type: BlogPostSchema,
2442
+
2443
+ // When you know the unique id
2444
+ id: pageId,
2445
+ // You can also use operations in these fields
2446
+ id: {in: pageIds},
2447
+ id: {notIn: pageIds},
2448
+
2449
+ // Using the path (e.g. querying).
2450
+ path: pagePath,
2451
+
2452
+ // Using the url (e.g. docs/content/query)
2453
+ url: pageUrl,
2454
+ // But sometimes you only need the URL to start with a certain prefix
2455
+ // instead of using javascript string manipulation you can use the startsWith field
2456
+ url: {startsWith: 'docs/content'}
2457
+
2458
+ // Using the parents id
2459
+ parentId: pageParentId
2460
+ })
2461
+ ```
2462
+
2463
+ Filtering (/docs/content/query/filtering)
2464
+
2465
+ ### 2. Using the location in the tree structure
2466
+
2467
+ ```
2468
+ await cms.first({
2469
+ // e.g. cms.workspaces.main
2470
+ workspace,
2471
+ // e.g. cms.workspaces.main.pages
2472
+ root,
2473
+ // Level: 0 = parent level, 1 = child level, 2 = grandchild level, ...
2474
+ level,
2475
+
2476
+ // For Location you can use a Root, Workspace or a Page as input.
2477
+ // e.g. cms.workspaces.main | cms.workspaces.main.pages
2478
+ location
2479
+ })
2480
+ ```
2481
+
2482
+ ### 3. Miscellaneous
2483
+
2484
+ ```
2485
+ await cms.first({
2486
+ // This can be: 'published', 'draft', 'archived', 'preferDraft', 'preferPublished', 'all'
2487
+ status: 'published',
2488
+ // Look for result with the exact locale.
2489
+ locale: pageLocale
2490
+ // Look for a result with the locale, but ignore when there are no exact matches
2491
+ preferredLocale: pageLocale
2492
+ })
2493
+ ```
2494
+
2495
+ ## Output control
2496
+
2497
+ Once you've filtered the results, you can manipulate the output. This can be done in two ways:
2498
+
2499
+ ### 1. Data
2500
+
2501
+ ```
2502
+ await cms.first({
2503
+ // Return everything from the related type
2504
+ type: BlogPostSchema,
2505
+ id: BlogPostId
2506
+ })
2507
+
2508
+ await cms.first({
2509
+ id: BlogPostId,
2510
+ // Only return the selected fields (similar to SQL's SELECT)
2511
+ select: {
2512
+ title: Query.title,
2513
+ description: Query.description
2514
+ }
2515
+ })
2516
+
2517
+ await cms.first({
2518
+ id: BlogPostId,
2519
+ select: {
2520
+ title: Query.title
2521
+ },
2522
+ // Use include to return extra fields that are not in the queried entry's schema
2523
+ include: {
2524
+ parent: Query.parent({
2525
+ select: {
2526
+ url: Query.url,
2527
+ title: Query.title
2528
+ }
2529
+ })
2530
+ }
2531
+ }
2532
+ ```
2533
+
2534
+ ### 2. Sorting
2535
+
2536
+ ```
2537
+ await cms.find({
2538
+ type: BlogPostSchema,
2539
+ select: {
2540
+ id: Query.id,
2541
+ title: Query.title
2542
+ },
2543
+ orderBy: {
2544
+ // Use desc or asc to sort by a specific field.
2545
+ desc: BlogPost.publishedDate,
2546
+ // This will sort the posts in alphabetical order
2547
+ asc: BlogPost.title
2548
+ },
2549
+ // Group results by 1 or more categories
2550
+ groupBy: BlogPost.category,
2551
+
2552
+ //skip the first 10 results and return a max of 20
2553
+ skip: 10,
2554
+ take: 20
2555
+ })
2556
+ ```
2557
+
2558
+ ### TypeScript (/docs/content/typescript)
2559
+ The type of a content [Type](/docs/configuration/schema/type) can be inferred.
2560
+
2561
+ ```
2562
+ import {Infer, Config, Field} from 'alinea'
2563
+
2564
+ const schema = {
2565
+ BlogOverview: Config.document('Blog overview', {
2566
+ contains: ['BlogPost']
2567
+ }),
2568
+ BlogPost: Config.document('Blog post', {
2569
+ fields: {
2570
+ publishDate: Field.date('Publish date'),
2571
+ body: Field.richText('Body')
2572
+ }
2573
+ })
2574
+ }
2575
+
2576
+ type BlogOverview = Infer<typeof schema.BlogOverview>
2577
+ type BlogPost = Infer<typeof schema.BlogPost>
2578
+ ```
2579
+
2580
+ ### Deploy (/docs/deploy)
2581
+ Once you're ready to deploy to production it's time to hook up Alinea to a backend. The Alinea handler needs access to a backend service that can authenticate users, store drafts and publish changes back to the git repository.
2582
+
2583
+ Framework: next
2584
+
2585
+ Update your [CMS config](/docs/configuration) to inform Alinea where to generate the admin dashboard.
2586
+
2587
+ ```
2588
+ export const cms = createCMS({
2589
+ // ...
2590
+ handlerUrl: '/api/cms',
2591
+ dashboardFile: 'admin.html'
2592
+ })
2593
+ ```
2594
+
2595
+ While building your project, an admin folder will be created together with the `dashboardFile` as defined within your [CMS config](/docs/configuration). Exclude the admin path and the staticFile from git by adding them to your `.gitignore` file:
2596
+
2597
+ File: .gitignore
2598
+ ```
2599
+ /public/admin*
2600
+ ```
2601
+
2602
+ Deploy your code and follow the instructions on `/admin.html` to configure your backend.
2603
+
2604
+ ### Alinea Cloud (/docs/deploy/alinea-cloud)
2605
+ [Alinea.cloud](https://www.alinea.cloud) provides a cloud service to set up a backend in a few clicks. It takes care of authentication, inviting other users to collaborate, and pushing changes to your git repository. After [exporting the handler and static files](/docs/deploy) you can follow the instructions by navigating to your admin dashboard (/admin.html).
2606
+
2607
+ ### Self-Hosted (/docs/deploy/self-host)
2608
+ The Alinea backend can be hosted on most Javascript runtimes if provided with a database (PostgreSQL, SQLite or Mysql) and a Github authentication token. Commits are persisted to the repository via the Github API. See a list of supported database drivers [here](https://github.com/benmerckx/rado?tab=readme-ov-file#supported-databases).
2609
+
2610
+ ## Hosting on Vercel
2611
+
2612
+ An example of hosting the backend on Vercel using the included Postgres database. Authentication is provided using basic HTTP authentication. Create the credentials and a Github token and store them in your environment variables.
2613
+
2614
+ File: app/(alinea)/api/cms/route.ts
2615
+ ```
2616
+ import {cms} from '@/cms'
2617
+ import {db} from '@vercel/postgres'
2618
+ import {createHandler} from 'alinea/next'
2619
+
2620
+ const handler = createHandler({
2621
+ cms,
2622
+ backend: {
2623
+ database: {
2624
+ driver: '@vercel/postgres',
2625
+ client: db
2626
+ },
2627
+ auth(username, password) {
2628
+ return (
2629
+ username === process.env.ALINEA_USERNAME &&
2630
+ password === process.env.ALINEA_PASSWORD
2631
+ )
2632
+ },
2633
+ github: {
2634
+ rootDir: 'apps/web',
2635
+ contentDir: 'content',
2636
+ authToken: process.env.ALINEA_GITHUB_TOKEN!,
2637
+ owner: process.env.ALINEA_GITHUB_OWNER!,
2638
+ repo: process.env.ALINEA_GITHUB_REPO!,
2639
+ branch: process.env.ALINEA_GITHUB_BRANCH!
2640
+ }
2641
+ }
2642
+ })
2643
+
2644
+ export const GET = handler
2645
+ export const POST = handler
2646
+ ```
2647
+
2648
+ ### Getting started (/docs/getting-started)
2649
+ Framework: next
2650
+
2651
+ ## 1. Setup Next.js
2652
+
2653
+ Get started by creating a new Next.js project. The following will setup a project in your chosen directory. If you'd like to add Alinea to an existing project, skip this step and continue with step 2.
2654
+
2655
+ ```shellscript
2656
+ npx create-next-app@latest
2657
+ ```
2658
+
2659
+ Note (info): Read the full instructions in the [Next.js docs](https://nextjs.org/docs/getting-started/installation)
2660
+
2661
+ ## 2. Install Alinea
2662
+
2663
+ Navigate to the newly created project directory and install the package with your preferred package manager.
2664
+
2665
+ Variant: npm
2666
+ ```shellscript
2667
+ npm install alinea
2668
+ ```
2669
+
2670
+ Variant: yarn
2671
+ ```shellscript
2672
+ yarn add alinea
2673
+ ```
2674
+
2675
+ Variant: pnpm
2676
+ ```shellscript
2677
+ pnpm install alinea
2678
+ ```
2679
+
2680
+ Variant: bun
2681
+ ```shellscript
2682
+ bun add alinea
2683
+ ```
2684
+
2685
+ ## 3. Initialize the project
2686
+
2687
+ Alinea requires a [config file](/docs/configuration) which can be auto-generated by running `alinea init`. If you prefer plain Javascript over Typescript, rename the created file from `cms.ts` to `cms.js`.
2688
+
2689
+ Variant: npm
2690
+ ```shellscript
2691
+ npx alinea init
2692
+ ```
2693
+
2694
+ Variant: yarn
2695
+ ```shellscript
2696
+ yarn alinea init
2697
+ ```
2698
+
2699
+ Variant: pnpm
2700
+ ```shellscript
2701
+ pnpm alinea init
2702
+ ```
2703
+
2704
+ Variant: bun
2705
+ ```shellscript
2706
+ bun alinea init
2707
+ ```
2708
+
2709
+ ## 4. Adjust Next.js config
2710
+
2711
+ To work around a few Next.js quirks some config changes are needed. Alinea exports a function to do this for you:
2712
+
2713
+ Variant: next.config.ts
2714
+ ```tsx
2715
+ import {withAlinea} from 'alinea/next'
2716
+
2717
+ const nextConfig = {...}
2718
+
2719
+ export default withAlinea(nextConfig)
2720
+ ```
2721
+
2722
+ Variant: next.config.js
2723
+ ```tsx
2724
+ const {withAlinea} = require('alinea/next')
2725
+
2726
+ const nextConfig = {...}
2727
+
2728
+ module.exports = withAlinea(nextConfig)
2729
+ ```
2730
+
2731
+ ## 5. Access the dashboard
2732
+
2733
+ Congratulations, Alinea is now ready to boot!
2734
+ Have a look around in the dashboard by running:
2735
+
2736
+ Variant: npm
2737
+ ```shellscript
2738
+ npx alinea dev
2739
+ ```
2740
+
2741
+ Variant: yarn
2742
+ ```shellscript
2743
+ yarn alinea dev
2744
+ ```
2745
+
2746
+ Variant: pnpm
2747
+ ```shellscript
2748
+ pnpm alinea dev
2749
+ ```
2750
+
2751
+ Variant: bun
2752
+ ```shellscript
2753
+ bun alinea dev
2754
+ ```
2755
+
2756
+ ### Reference (/docs/reference)
2757
+ Working with Alinea should feel intuitive but if you're looking for more in-depth information or feel like something is missing have a look here.
2758
+
2759
+ ### Agents playbook (/docs/reference/agents-playbook)
2760
+ This page is the operational playbook for coding agents that need to create or update Alinea content JSON directly. It focuses on deterministic rules that match Alinea core behavior and this repository's content shapes.
2761
+
2762
+ ## Scope and source priority
2763
+
2764
+ When documenting or generating Alinea content, use source of truth in this order:
2765
+
2766
+ - The Alinea full-llms.txt file holds all documentation (including this playbook section). Use this as primary source of truth.
2767
+ - In case of ambiguity or missing documentation, consult the alinea core source code. - Projects using alinea will typically have alinea as a bundled node_modules dependency, which means the (compiled) source code can be accessed directly. - In case the source code can not be retrieved, or the compiled code is unclear, consult the source code on https://github.com/alineacms/alinea
2768
+ - Consult schema implementations and concrete JSON fixtures/content in the target project (for example `content/main/**`, `content/demo/**`) to find helpful examples and try to following the same structure when suggesting changes.
2769
+ - The source code of the alinea documentation website is also publicly available on https://github.com/alineacms/alineacms.com. The documentation website is itself created with Alinea + Next.js and can serve as a useful example.
2770
+
2771
+ ## Project structure conventions
2772
+
2773
+ In new Next.js projects, agents should follow the tutorial file structure as closely as possible to keep the codebase readable and maintainable.
2774
+
2775
+ In existing codebases, first scan the current structure and then align new files and changes to the established coding guidelines and architectural principles.
2776
+
2777
+ ## Entry metadata rules
2778
+
2779
+ Top-level entries are JSON records with required meta fields. In Alinea core this is defined by `EntryMeta` in `src/core/EntryRecord.ts`.
2780
+
2781
+ ```json
2782
+ {
2783
+ "_id": "<createId()>",
2784
+ "_type": "<schema type name>",
2785
+ "_index": "<fractional index>",
2786
+ "_root": "pages",
2787
+ "_seeded": "/index.json",
2788
+ "title": "..."
2789
+ }
2790
+ ```
2791
+
2792
+ - `_id`: unique entry id from `createId()` (`alinea/core/Id`).
2793
+ - `_type`: exact schema type key.
2794
+ - `_index`: fractional ordering key. Generate with `generateKeyBetween` from `alinea/core/util/FractionalIndexing`.
2795
+ - `_root`: required for root-level entries (entries without a parent). Value is the workspace root key, for example `pages` or `media`.
2796
+ - `_seeded`: only for entries that correspond to seeded config pages (`Config.page(...)`). Keep path stable.
2797
+
2798
+ ## ID and index generation
2799
+
2800
+ ```shellscript
2801
+ # New id
2802
+ node --input-type=module -e "import {createId} from 'alinea/core/Id'; console.log(createId())"
2803
+
2804
+ # Index between two siblings
2805
+ node --input-type=module -e "import {generateKeyBetween} from 'alinea/core/util/FractionalIndexing'; console.log(generateKeyBetween('a0', 'a1'))"
2806
+
2807
+ # Append after last sibling
2808
+ node --input-type=module -e "import {generateKeyBetween} from 'alinea/core/util/FractionalIndexing'; console.log(generateKeyBetween('a0', null))"
2809
+ ```
2810
+
2811
+ Alinea sorts sibling entries by `_index` ascending. Never hand-pick `_index` by eye when inserting between entries.
2812
+
2813
+ ## Internal and external links
2814
+
2815
+ Links appear in two places: rich text marks and link fields (`Field.link` / `Field.link.multiple`).
2816
+
2817
+ ### Rich text link marks
2818
+
2819
+ ```json
2820
+ [
2821
+ {
2822
+ "_type": "paragraph",
2823
+ "content": [
2824
+ {
2825
+ "_type": "text",
2826
+ "text": "Internal doc",
2827
+ "marks": [
2828
+ {
2829
+ "_type": "link",
2830
+ "_id": "<createId()>",
2831
+ "_link": "entry",
2832
+ "_entry": "<target entry id>"
2833
+ }
2834
+ ]
2835
+ },
2836
+ {
2837
+ "_type": "text",
2838
+ "text": " and external site",
2839
+ "marks": [
2840
+ {
2841
+ "_type": "link",
2842
+ "_id": "<createId()>",
2843
+ "_link": "url",
2844
+ "href": "https://example.com",
2845
+ "target": "_blank",
2846
+ "title": ""
2847
+ }
2848
+ ]
2849
+ }
2850
+ ]
2851
+ }
2852
+ ]
2853
+ ```
2854
+
2855
+ Rich text link mark shape is defined by `LinkMark` in `src/core/TextDoc.ts`: `_type: link`, `_id`, `_link` (`entry` | `file` | `url`), and optional `_entry`.
2856
+
2857
+ ### Link field objects
2858
+
2859
+ ```javascript
2860
+ // Field.link('Link') -> single entry link
2861
+ {
2862
+ "_id": "<createId()>",
2863
+ "_type": "entry",
2864
+ "_entry": "<target entry id>",
2865
+ "label": "Optional extra field"
2866
+ }
2867
+
2868
+ // Field.link('Link') -> single external url
2869
+ {
2870
+ "_id": "<createId()>",
2871
+ "_type": "url",
2872
+ "_url": "https://example.com",
2873
+ "_title": "Example",
2874
+ "_target": "_blank",
2875
+ "label": "Optional extra field"
2876
+ }
2877
+
2878
+ // Field.link.multiple('Links') row
2879
+ {
2880
+ "_id": "<createId()>",
2881
+ "_index": "<fractional index>",
2882
+ "_type": "entry",
2883
+ "_entry": "<target entry id>",
2884
+ "label": "Optional extra field"
2885
+ }
2886
+ ```
2887
+
2888
+ For `Field.link.multiple`, each row is also a list row, so `_index` is required.
2889
+
2890
+ ## Lists and union/list row metadata
2891
+
2892
+ List rows are defined by `ListRow` in `src/core/shape/ListShape.ts`. Every row must include `_id`, `_type`, `_index`.
2893
+
2894
+ ```json
2895
+ {
2896
+ "items": [
2897
+ {
2898
+ "_id": "<createId()>",
2899
+ "_index": "a0",
2900
+ "_type": "Item",
2901
+ "title": "First"
2902
+ },
2903
+ {
2904
+ "_id": "<createId()>",
2905
+ "_index": "a1",
2906
+ "_type": "Item",
2907
+ "title": "Second"
2908
+ }
2909
+ ]
2910
+ }
2911
+ ```
2912
+
2913
+ Union values (from `UnionShape`) require `_id` and `_type`. If a union is inside a list, it still needs list row `_index` as well.
2914
+
2915
+ ## Rich text JSON format
2916
+
2917
+ Alinea rich text is a `TextDoc` array (`src/core/TextDoc.ts`). Common internal nodes used by this project include `heading`, `paragraph`, `text`, `bulletList`, `orderedList`, `listItem`, `hardBreak`, and block nodes like `CodeBlock`/`ImageBlock` with `_id`.
2918
+
2919
+ ```json
2920
+ [
2921
+ {
2922
+ "_type": "heading",
2923
+ "level": 2,
2924
+ "content": [{"_type": "text", "text": "Heading"}]
2925
+ },
2926
+ {
2927
+ "_type": "paragraph",
2928
+ "textAlign": "left",
2929
+ "content": [
2930
+ {"_type": "text", "text": "Normal text "},
2931
+ {
2932
+ "_type": "text",
2933
+ "text": "bold",
2934
+ "marks": [{"_type": "bold"}]
2935
+ },
2936
+ {"_type": "text", "text": " "},
2937
+ {
2938
+ "_type": "text",
2939
+ "text": "italic",
2940
+ "marks": [{"_type": "italic"}]
2941
+ },
2942
+ {"_type": "hardBreak"},
2943
+ {
2944
+ "_type": "text",
2945
+ "text": "anchor",
2946
+ "marks": [
2947
+ {
2948
+ "_type": "link",
2949
+ "_id": "<createId()>",
2950
+ "_link": "url",
2951
+ "href": "https://example.com",
2952
+ "target": "_blank",
2953
+ "title": ""
2954
+ }
2955
+ ]
2956
+ }
2957
+ ]
2958
+ },
2959
+ {
2960
+ "_type": "bulletList",
2961
+ "content": [
2962
+ {
2963
+ "_type": "listItem",
2964
+ "content": [
2965
+ {
2966
+ "_type": "paragraph",
2967
+ "content": [{"_type": "text", "text": "Bullet item"}]
2968
+ }
2969
+ ]
2970
+ }
2971
+ ]
2972
+ },
2973
+ {
2974
+ "_type": "orderedList",
2975
+ "start": 1,
2976
+ "content": [
2977
+ {
2978
+ "_type": "listItem",
2979
+ "content": [
2980
+ {
2981
+ "_type": "paragraph",
2982
+ "content": [{"_type": "text", "text": "Ordered item"}]
2983
+ }
2984
+ ]
2985
+ }
2986
+ ]
2987
+ },
2988
+ {
2989
+ "_type": "CodeBlock",
2990
+ "_id": "<createId()>",
2991
+ "code": "console.log('block nodes need _id')",
2992
+ "language": "javascript",
2993
+ "fileName": "",
2994
+ "compact": false
2995
+ }
2996
+ ]
2997
+ ```
2998
+
2999
+ If generating from HTML, Alinea's parser maps common tags to these node/mark types (`src/core/field/RichTextField.ts`), for example `<p>` -> `paragraph`, `<a>` -> `link`, `<ul>/<ol>/<li>` -> list nodes, `<strong>` -> `bold`.
3000
+
3001
+ ## Roots and workspaces
3002
+
3003
+ `Config.workspace` and `Config.root` define where content is stored and which root key each entry belongs to (`src/core/Workspace.ts`, `src/core/Root.ts`).
3004
+
3005
+ ```text
3006
+ // Example workspace layout in this repository
3007
+ content/
3008
+ main/
3009
+ pages/
3010
+ docs.json
3011
+ docs/
3012
+ reference/
3013
+ cli.json
3014
+ media/
3015
+ screenshot.json
3016
+ demo/
3017
+ pages/
3018
+ index.json
3019
+ recipes/
3020
+ chocolate-chip.json
3021
+ media/
3022
+ ...
3023
+
3024
+ // Workspace key -> source
3025
+ main -> content/main
3026
+ demo -> content/demo
3027
+
3028
+ // Root key -> folder under each workspace source
3029
+ pages -> <workspace>/pages
3030
+ media -> <workspace>/media
3031
+ ```
3032
+
3033
+ Manual generation rules:
3034
+
3035
+ - When creating a root-level entry file, include `_root` with the matching root key.
3036
+ - Place files under the workspace source directory and root directory that match config.
3037
+ - For seeded pages, keep `_seeded` stable and matching the configured seed path.
3038
+ - Do not remove nested identity fields (`_id`, `_index`, `_type`) from list rows, union values, or rich text block nodes.
3039
+
3040
+ ## Validation workflow
3041
+
3042
+ ```shellscript
3043
+ # Normalize metadata via Alinea fix path
3044
+ alinea build --fix
3045
+
3046
+ # In this repository
3047
+ bun run build -- --fix
3048
+
3049
+ # Final validation
3050
+ bun run build
3051
+ ```
3052
+
3053
+ Before commit: ensure JSON parses, `_type` matches schema, `_index` order is correct among siblings, and no duplicate `_id` values were introduced in edited scope.
3054
+
3055
+ ### CLI (/docs/reference/cli)
3056
+ ```
3057
+ Usage
3058
+ $ alinea <command> [options]
3059
+
3060
+ Available Commands
3061
+ init Copy a sample config file to the current directory
3062
+ dev Start development dashboard
3063
+ build Generate content cache
3064
+
3065
+ For more info, run any command with the `--help` flag
3066
+ $ alinea build --help
3067
+ $ alinea init --help
3068
+
3069
+ Options
3070
+ -v, --version Displays current version
3071
+ -h, --help Displays this message
3072
+ ```
3073
+
3074
+ ## Commands
3075
+
3076
+ ### alinea init
3077
+
3078
+ Creates an example config file. Run once during setup.
3079
+
3080
+ ### alinea dev
3081
+
3082
+ Starts a development server to display the alinea dashboard. It can be prepended to another development command to run it in parallel.
3083
+
3084
+ ```
3085
+ // Example: start the dashboard and next development server
3086
+ alinea dev -- next dev
3087
+ ```
3088
+
3089
+ ```
3090
+ Options
3091
+ -p, --port Port to listen on
3092
+ --production Use production backend
3093
+ ```
3094
+
3095
+ ### alinea build
3096
+
3097
+ The build command indexes content and generates the `@alinea/generated` package. It's recommended to run it as part of the build step in your project.
3098
+
3099
+ ```
3100
+ Options
3101
+ -w, --watch Watch for changes to source files
3102
+ -c, --config Location of the config file, defaults to "cms.ts"
3103
+ --fix Any missing or incorrect properties will be overwritten by their default
3104
+ ```
3105
+
3106
+ ### Internationalization (/docs/reference/internationalization)
3107
+ Alinea is built with internalization in mind. It's possible to make content available in more than one language or region. To get started configure the `i18n` on a [Root](/docs/configuration/workspaces/root) in your schema. It requires a list of supported locales. Any entry created within this root can then be translated to the other languages. The url of these entries will have the locale prepended (this behaviour can be configured, see the `entryUrl` property when creating a [Type](/docs/configuration/schema/type)).
3108
+
3109
+ ### Tutorial (/docs/tutorial)
3110
+ This tutorial is an opinionated, Next.js-first setup for building a real website with Alinea. It starts with one fixed homepage entry and grows into blocks, shared layout content, a blog, and finally a catch-all route.
3111
+
3112
+ ## Conventions used in every step
3113
+
3114
+ Each page type and block type lives in its own folder with a server component and schema file. We found that this is the most convenient structure for readability and maintenance. Every page/block is responsible for collecting additional data/content when necessary. You can turn each server component into [cache components](https://nextjs.org/docs/app/getting-started/cache-components) when you see fit, giving you a lot of flexibility with maximum performance. Step 4 demonstrates this with a server-rendered blog post page that fetches sibling posts for Next/Previous navigation.
3115
+
3116
+ Continue with the five steps in this section.
3117
+
3118
+ ### Step 1: Landing page (/docs/tutorial/step-1-landing-page)
3119
+ We're going to work towards setting up a welcome page, served on the root domain of the website. This page will simply print the title of the page, which can be adjusted in the CMS.
3120
+
3121
+ Image: step1 (/step1)
3122
+
3123
+ We recommend creating a single component file for every page. In this case we call it LandingPage.tsx. This React component will be accompagnied by a schema file, which defines the fields of this page.
3124
+
3125
+ File: project structure
3126
+ ```
3127
+ app/
3128
+ ╰ page.tsx
3129
+ ╰ layout.tsx
3130
+
3131
+ entries/
3132
+ ╰ landing/
3133
+ ├ LandingPage.tsx
3134
+ ╰ LandingPage.schema.tsx
3135
+
3136
+ cms.tsx
3137
+ ```
3138
+
3139
+ We start by defining the landing entry schema in its own file. Its path is fixed to an empty string and made read only so it always resolves to /.
3140
+
3141
+ File: entries/landing/LandingPage.schema.tsx
3142
+ ```tsx
3143
+ import {Config, Field} from 'alinea'
3144
+
3145
+ export const LandingPage = Config.document('Landing page', {
3146
+ fields: {
3147
+ title: Field.text('Title', {required: true, width: 0.5}),
3148
+ path: Field.path('Path', {readOnly: true, width: 0.5, initialValue: ''})
3149
+ }
3150
+ })
3151
+ ```
3152
+
3153
+ Then we set up the Page-component itself. We define this as a server component and make it responsible for fetching the required data from the CMS. Notice we defined the LandingPage schema as a document, which means it automatically receives various metadata fields. We fetch this from the CMS to properly implement a generateMetadata function.
3154
+
3155
+ File: entries/landing/LandingPage.tsx
3156
+ ```tsx
3157
+ import type {Metadata} from 'next'
3158
+ import {notFound} from 'next/navigation'
3159
+ import {cms} from '@/cms'
3160
+ import {LandingPage} from './LandingPage.schema'
3161
+
3162
+ export async function LandingPageView() {
3163
+ const page = await cms.get({url: '/', type: LandingPage})
3164
+ if (!page) notFound()
3165
+
3166
+ return (
3167
+ <main>
3168
+ <h1>{page.title}</h1>
3169
+ </main>
3170
+ )
3171
+ }
3172
+
3173
+ export async function generateMetadata(): Promise<Metadata> {
3174
+ const page = await cms.get({url: '/', type: LandingPage})
3175
+ if (!page) return {}
3176
+
3177
+ return {
3178
+ title: page.metadata.title || page.title,
3179
+ description: page.metadata?.description,
3180
+ openGraph: {
3181
+ title: page.metadata.openGraph.title || page.metadata.title || page.title,
3182
+ description: page.metadata.openGraph.description || page.metadata?.description,
3183
+ images: page.metadata?.openGraph.image
3184
+ ? [page.metadata?.openGraph.image.src]
3185
+ : undefined
3186
+ }
3187
+ }
3188
+ }
3189
+ ```
3190
+
3191
+ Register the landing schema in the cms.tsx and (optionally) seed the initial homepage entry.
3192
+
3193
+ File: cms.tsx
3194
+ ```tsx
3195
+ import {Config} from 'alinea'
3196
+ import {createCMS} from 'alinea/next'
3197
+ import {LandingPage} from '@/entries/landing/LandingPage.schema'
3198
+
3199
+ export const cms = createCMS({
3200
+ schema: {LandingPage},
3201
+ workspaces: {
3202
+ main: Config.workspace('Main', {
3203
+ source: 'content',
3204
+ mediaDir: 'public',
3205
+ roots: {
3206
+ pages: Config.root('Pages', {
3207
+ contains: ['LandingPage'],
3208
+ children: {
3209
+ // Optionally seed this page, alternatively you can simply create the page from the CMS directly
3210
+ index: Config.page({
3211
+ type: LandingPage,
3212
+ fields: {
3213
+ title: 'Welcome',
3214
+ path: ''
3215
+ }
3216
+ })
3217
+ }
3218
+ }),
3219
+ media: Config.media()
3220
+ }
3221
+ })
3222
+ },
3223
+ baseUrl: {
3224
+ development: 'http://localhost:3000'
3225
+ },
3226
+ handlerUrl: '/api/cms',
3227
+ dashboardFile: 'admin.html',
3228
+ preview: true
3229
+ })
3230
+ ```
3231
+
3232
+ Finally, we implement the page.tsx and layout.tsx files in our app folder to fully wire this site up like a standard Next.js project. Notice we added the previews widget to the root layout, this will activate live previews for content editors.
3233
+
3234
+ File: app/page.tsx
3235
+ ```tsx
3236
+ import {LandingPageView} from '@/entries/landing/LandingPage'
3237
+
3238
+ export {generateMetadata} from '@/entries/landing/LandingPage'
3239
+
3240
+ export default function Page() {
3241
+ return <LandingPageView />
3242
+ }
3243
+ ```
3244
+
3245
+ File: app/layout.tsx
3246
+ ```tsx
3247
+ import type {Metadata} from 'next'
3248
+ import {cms} from '@/cms'
3249
+
3250
+ export default function RootLayout({children}: {children: React.ReactNode}) {
3251
+ return (
3252
+ <html lang="en">
3253
+ <body>
3254
+ {children}
3255
+ <cms.previews widget />
3256
+ </body>
3257
+ </html>
3258
+ )
3259
+ }
3260
+ ```
3261
+
3262
+ Note (info): Upon completion of these steps, you should have a minimal working website with these verifiable features:
3263
+
3264
+ - Upon starting your CMS, a single page entry representing your landing page is present.
3265
+ - Live previews are enabled, when editing the title of your landing page the previewed page should reflect these any changes.
3266
+ - Wired up metadata: try navigating to the metadata tab and adjusting any of the content.
3267
+
3268
+ ### Step 2: Content blocks (/docs/tutorial/step-2-block-list)
3269
+ Websites are often build up with a variety of full-width "page blocks". Content editors can freely manage these building blocks and mix-and-match them to create compelling web content in a curated environment.
3270
+
3271
+ We will expand our simple landing page and allow content editors to build up content with text blocks, image blocks and a configurable weather block. Alinea has a [list-field](/docs/configuration/fields/list) which can be used to model this.
3272
+
3273
+ Image: step2 (/step2)
3274
+
3275
+ Just like for pages, we recommend using a single component and schema file for every block type. Blocks
3276
+
3277
+ File: project structure
3278
+ ```
3279
+ app/
3280
+ ╰ page.tsx
3281
+ ╰ layout.tsx
3282
+
3283
+ entries/
3284
+ ╰ landing/
3285
+ ├ LandingPage.tsx
3286
+ ╰ LandingPage.schema.tsx
3287
+
3288
+ blocks/
3289
+ ├ text/
3290
+ │ ├ TextBlock.tsx
3291
+ │ ╰ TextBlock.schema.tsx
3292
+ ├ image/
3293
+ │ ├ ImageBlock.tsx
3294
+ │ ╰ ImageBlock.schema.tsx
3295
+ ╰ weather/
3296
+ ├ WeatherBlock.tsx
3297
+ ╰ WeatherBlock.schema.tsx
3298
+
3299
+ cms.tsx
3300
+ ```
3301
+
3302
+ Create each block type as a schema and a matching view component. The text block is composed of a single [rich text field](/docs/configuration/fields/rich-text), which we mark as inline to reduce noise for content editors.
3303
+
3304
+ File: blocks/text/TextBlock.schema.tsx
3305
+ ```tsx
3306
+ import {Config, Field} from 'alinea'
3307
+
3308
+ export const TextBlock = Config.type('Text block', {
3309
+ fields: {
3310
+ body: Field.richText('Text', {inline: true})
3311
+ }
3312
+ })
3313
+ ```
3314
+
3315
+ Alinea exposes a RichText component, which can be used to wrap rich text data and turn it into safely rendered HTML-output. The component allows for easy extending and styling of html components and even full-customizable, nested blocks.
3316
+
3317
+ File: blocks/text/TextBlock.tsx
3318
+ ```tsx
3319
+ import type {Infer} from 'alinea'
3320
+ import {RichText} from 'alinea/ui'
3321
+ import NextLink from 'next/link'
3322
+ import type {TextBlock} from './TextBlock.schema'
3323
+
3324
+ type TextBlockData = Infer.ListItem<typeof TextBlock>
3325
+
3326
+ function Link({href, ...props}: {href?: string; [key: string]: any}) {
3327
+ if (!href) return <a {...props} />
3328
+ return <NextLink href={href!} {...props} />
3329
+ }
3330
+
3331
+ export function TextBlockView({block}: {block: TextBlockData}) {
3332
+ return <RichText doc={block.body} a={Link} />
3333
+ }
3334
+ ```
3335
+
3336
+ The ImageBlock fields are quite straightforward.
3337
+
3338
+ File: blocks/image/ImageBlock.schema.tsx
3339
+ ```tsx
3340
+ import {Config, Field} from 'alinea'
3341
+
3342
+ export const ImageBlock = Config.type('Image block', {
3343
+ fields: {
3344
+ image: Field.image('Image', {required: true, width: 0.5}),
3345
+ alt: Field.text('Alt text', {width: 0.5})
3346
+ }
3347
+ })
3348
+ ```
3349
+
3350
+ We use the [nextjs Image component](https://nextjs.org/docs/app/api-reference/components/image) to render the image, which will make sure the image is cropped and optimized.
3351
+
3352
+ File: blocks/image/ImageBlock.tsx
3353
+ ```tsx
3354
+ import type {Infer} from 'alinea'
3355
+ import Image from 'next/image'
3356
+ import type {ImageBlock} from './ImageBlock.schema'
3357
+
3358
+ type ImageBlockData = Infer.ListItem<typeof ImageBlock>
3359
+
3360
+ export function ImageBlockView({block}: {block: ImageBlockData}) {
3361
+ if (!block.image) return null
3362
+
3363
+ const {src, width, height} = block.image
3364
+ return (
3365
+ <Image
3366
+ src={src}
3367
+ width={width}
3368
+ height={height}
3369
+ alt={block.alt || ''}
3370
+ style={{width: '300px', height: 'auto'}}
3371
+ />
3372
+ )
3373
+ }
3374
+ ```
3375
+
3376
+ Finally we define a WeatherBlock. Content editors can define a region, from which the geo location will be determined and the weather forecast predicted.
3377
+
3378
+ File: blocks/weather/WeatherBlock.schema.tsx
3379
+ ```tsx
3380
+ import {Config, Field} from 'alinea'
3381
+
3382
+ export const WeatherBlock = Config.type('Weather block', {
3383
+ fields: {
3384
+ title: Field.text('Title', {required: true, width: 0.5}),
3385
+ region: Field.text('Region', {
3386
+ required: true,
3387
+ width: 0.5,
3388
+ help: 'City or region name, for example: Brussels or New York'
3389
+ })
3390
+ }
3391
+ })
3392
+ ```
3393
+
3394
+ The WeatherBlock is implemented as a cached, server component. The component is fully responsible for fetching the data it requires. Data is cached for a maximum of 15 minutes.
3395
+
3396
+ File: blocks/weather/WeatherBlock.tsx
3397
+ ```tsx
3398
+ import {Infer} from 'alinea'
3399
+ import {unstable_cacheLife as cacheLife} from 'next/cache'
3400
+ import {WeatherBlock} from './WeatherBlock.schema'
3401
+
3402
+ type WeatherBlockData = Infer.ListItem<typeof WeatherBlock>
3403
+
3404
+ const weatherCodeLabels = {
3405
+ 0: 'Clear sky',
3406
+ 1: 'Mainly clear',
3407
+ ...,
3408
+ 95: 'Thunderstorm'
3409
+ }
3410
+
3411
+ async function getCurrentWeather(region: string) {
3412
+ 'use cache'
3413
+ cacheLife({stale: 900, revalidate: 900, expire: 900})
3414
+
3415
+ const geocoding = await fetch(
3416
+ 'https://geocoding-api.open-meteo.com/v1/search?name=' + encodeURIComponent(region) + '&count=1'
3417
+ )
3418
+ if (!geocoding.ok) return null
3419
+
3420
+ const result = (await geocoding.json()).results?.[0]
3421
+ if (!result) return null
3422
+
3423
+ const forecast = await fetch(
3424
+ 'https://api.open-meteo.com/v1/forecast?latitude=' +
3425
+ result.latitude +
3426
+ '&longitude=' +
3427
+ result.longitude +
3428
+ '&current=temperature_2m,weather_code&timezone=auto'
3429
+ )
3430
+ if (!forecast.ok) return null
3431
+
3432
+ const current = (await forecast.json()).current
3433
+ if (!current) return null
3434
+
3435
+ return {
3436
+ location: result.country ? result.name + ', ' + result.country : result.name,
3437
+ temperature: current.temperature_2m,
3438
+ summary: weatherCodeLabels[current.weather_code] ?? 'Current weather'
3439
+ }
3440
+ }
3441
+
3442
+ export async function WeatherBlockView({block}: {block: WeatherBlockData}) {
3443
+ const weather = await getCurrentWeather(block.region)
3444
+ if(!weather) return null
3445
+ return <p>{block.title}: {weather.location}, {weather.temperature}°C ({weather.summary})</p>
3446
+ }
3447
+ ```
3448
+
3449
+ Extend the landing schema by adding a blocks list that references those block schemas.
3450
+
3451
+ File: entries/landing/LandingPage.schema.tsx
3452
+ ```tsx
3453
+ import {Config, Field} from 'alinea'
3454
+ import {ImageBlock} from '@/blocks/image/ImageBlock.schema'
3455
+ import {TextBlock} from '@/blocks/text/TextBlock.schema'
3456
+ import {WeatherBlock} from '@/blocks/weather/WeatherBlock.schema'
3457
+
3458
+ export const LandingPage = Config.document('Landing page', {
3459
+ fields: {
3460
+ title: Field.text('Title', {required: true, width: 0.5}),
3461
+ path: Field.path('Path', {readOnly: true, width: 0.5, initialValue: ''}),
3462
+ blocks: Field.list('Blocks', {
3463
+ schema: {
3464
+ TextBlock,
3465
+ ImageBlock,
3466
+ WeatherBlock
3467
+ }
3468
+ })
3469
+ }
3470
+ })
3471
+ ```
3472
+
3473
+ Update the step landing page view from the previous step. Keep it as a server component that fetches the page and generates metadata, then render each block variant based on _type. We also illustrate how to generate a fall-back metadata description.
3474
+
3475
+ File: entries/landing/LandingPage.tsx
3476
+ ```tsx
3477
+ import type {TextDoc} from 'alinea'
3478
+ import {Node} from 'alinea/core/TextDoc'
3479
+ import type {Metadata} from 'next'
3480
+ import {notFound} from 'next/navigation'
3481
+ import {ImageBlockView} from '@/blocks/image/ImageBlock'
3482
+ import {TextBlockView} from '@/blocks/text/TextBlock'
3483
+ import {WeatherBlockView} from '@/blocks/weather/WeatherBlock'
3484
+ import {cms} from '@/cms'
3485
+ import {LandingPage} from './LandingPage.schema'
3486
+
3487
+ export async function LandingPageView() {
3488
+ const page = await cms.get({url: '/', type: LandingPage})
3489
+ if (!page) notFound()
3490
+
3491
+ return (
3492
+ <main>
3493
+ <h1>{page.title}</h1>
3494
+ {page.blocks.map(block => {
3495
+ if (block._type === 'TextBlock') return <TextBlockView key={block._id} block={block} />
3496
+ if (block._type === 'ImageBlock') return <ImageBlockView key={block._id} block={block} />
3497
+ if (block._type === 'WeatherBlock') return <WeatherBlockView key={block._id} block={block} />
3498
+ return null
3499
+ })}
3500
+ </main>
3501
+ )
3502
+ }
3503
+
3504
+ export async function generateMetadata(): Promise<Metadata> {
3505
+ const page = await cms.get({url: '/', type: LandingPage})
3506
+ if (!page) return {}
3507
+
3508
+ let fallbackDescription = ''
3509
+ for (const block of page.blocks) {
3510
+ if (block._type === 'TextBlock') {
3511
+ fallbackDescription = plainText(block.body)
3512
+ break
3513
+ }
3514
+ }
3515
+
3516
+ return {
3517
+ title: page.metadata.title || page.title,
3518
+ description: page.metadata?.description || fallbackDescription,
3519
+ openGraph: {
3520
+ title: page.metadata.openGraph.title || page.metadata.title || page.title,
3521
+ description: page.metadata.openGraph.description || page.metadata?.description,
3522
+ images: page.metadata?.openGraph.image
3523
+ ? [page.metadata?.openGraph.image.src]
3524
+ : undefined
3525
+ }
3526
+ }
3527
+ }
3528
+
3529
+ export function plainText(value: TextDoc<any> | string | undefined): string {
3530
+ if (!value) return ''
3531
+ if (typeof value === 'string') return value
3532
+
3533
+ if (!Array.isArray(value)) return ''
3534
+ const result = value
3535
+ .reduce((acc, node) => {
3536
+ return acc + textOf(node)
3537
+ }, '')
3538
+ .trim()
3539
+ return result.replace(/ +(?= )/g, '')
3540
+ }
3541
+
3542
+ function textOf(node: Node): string {
3543
+ if (node._type === 'hardBreak') return '\n'
3544
+ if (Node.isText(node)) {
3545
+ return node.text ? ' ' + node.text : ''
3546
+ } else if (Node.isElement(node) && node.content) {
3547
+ return node.content.reduce((acc, node) => {
3548
+ return acc + textOf(node)
3549
+ }, '')
3550
+ }
3551
+ return ''
3552
+ }
3553
+ ```
3554
+
3555
+ Note (info): Upon completion of these steps, your landing page should be extended with these features:
3556
+
3557
+ - A system of content blocks which can be rearranged at will.
3558
+ - An example of a rendered, rich text field.
3559
+ - An example of a more complex block, which fetches data from an external API.
3560
+ - Metadata fallback: content of the first text block is used as fallback description.
3561
+
3562
+ ### Step 3: Shared root (/docs/tutorial/step-3-layout-root)
3563
+ In this step we keep the landing page from the previous steps and add a dedicated settings root for layout content shared by all pages. We will define a global settings entry which holds information about the top menu or global footer
3564
+
3565
+ Other examples you might want to include in this root:
3566
+
3567
+ - Taxonomy lists (eg. for tagging)
3568
+ - Shared content such as a list of authors
3569
+ - A dictionary of words used across the website that are not tied to a specific page
3570
+
3571
+ Image: step3 (/step3)
3572
+
3573
+ Under entries we define settings, just like the pages and blocks we split this logic in 2 files. One to define the schema and one to define the corresponding React views.
3574
+
3575
+ File: project structure
3576
+ ```
3577
+ app/
3578
+ ├ page.tsx
3579
+ ╰ layout.tsx
3580
+
3581
+ entries/
3582
+ ├ landing/
3583
+ │ ╰ ...
3584
+ ╰ settings/
3585
+ ├ SiteLayout.tsx
3586
+ ╰ SiteLayout.schema.tsx
3587
+
3588
+ blocks/
3589
+ ╰ ...
3590
+
3591
+ cms.tsx
3592
+ ```
3593
+
3594
+ Define a dedicated entry type for shared layout content used site-wide, and disable preview for this global settings entry. Notice that we use Config.type here, instead of Config.document. We don't need metadata fields on this type of entry.
3595
+
3596
+ File: entries/settings/SiteLayout.schema.tsx
3597
+ ```tsx
3598
+ import {Config, Field} from 'alinea'
3599
+
3600
+ export const SiteLayout = Config.type('Site layout', {
3601
+ preview: false,
3602
+ fields: {
3603
+ title: Field.text('Entry title', {initialValue: 'Global settings', width: 0.5}),
3604
+ path: Field.path('Path', {readOnly: true, initialValue: 'settings', width: 0.5}),
3605
+ headerText: Field.text('Header text', {required: true}),
3606
+ footerText: Field.text('Footer text', {required: true})
3607
+ }
3608
+ })
3609
+ ```
3610
+
3611
+ Create small header and footer components and infer their props from the SiteLayout schema.
3612
+
3613
+ File: entries/settings/SiteLayout.tsx
3614
+ ```tsx
3615
+ import type {Infer} from 'alinea'
3616
+ import {SiteLayout as SiteLayoutEntry} from './SiteLayout.schema'
3617
+
3618
+ type SiteLayoutProps = Infer.Entry<typeof SiteLayoutEntry>
3619
+
3620
+ export function SiteHeader({settings}: {settings: SiteLayoutProps}) {
3621
+ return <header>{settings.headerText}</header>
3622
+ }
3623
+
3624
+ export function SiteFooter({settings}: {settings: SiteLayoutProps}) {
3625
+ return <footer>{settings.footerText}</footer>
3626
+ }
3627
+ ```
3628
+
3629
+ Register SiteLayout in a dedicated settings root and (optionally) seed one entry.
3630
+
3631
+ File: cms.tsx
3632
+ ```tsx
3633
+ import {Config} from 'alinea'
3634
+ import {createCMS} from 'alinea/next'
3635
+ import type {SVGProps} from 'react'
3636
+ import {LandingPage} from '@/entries/landing/LandingPage.schema'
3637
+ import {SiteLayout} from '@/entries/settings/SiteLayout.schema'
3638
+
3639
+ export const cms = createCMS({
3640
+ schema: {
3641
+ LandingPage,
3642
+ SiteLayout
3643
+ },
3644
+ workspaces: {
3645
+ main: Config.workspace('Main', {
3646
+ source: 'content',
3647
+ mediaDir: 'public',
3648
+ roots: {
3649
+ pages: Config.root('Pages', {
3650
+ contains: ['LandingPage'],
3651
+ children: {
3652
+ index: Config.page({
3653
+ type: LandingPage,
3654
+ fields: {
3655
+ title: 'Welcome',
3656
+ path: ''
3657
+ }
3658
+ })
3659
+ }
3660
+ }),
3661
+ settings: Config.root('Settings', {
3662
+ icon: MaterialSymbolsSettingsOutline,
3663
+ contains: ['SiteLayout'],
3664
+ children: {
3665
+ settings: Config.page({
3666
+ type: SiteLayout,
3667
+ fields: {
3668
+ title: 'Global settings',
3669
+ path: 'settings',
3670
+ headerText: 'My website',
3671
+ footerText: 'Copyright 2026'
3672
+ }
3673
+ })
3674
+ }
3675
+ }),
3676
+ media: Config.media()
3677
+ }
3678
+ })
3679
+ },
3680
+ baseUrl: {
3681
+ development: 'http://localhost:3103'
3682
+ },
3683
+ handlerUrl: '/api/cms',
3684
+ dashboardFile: 'admin.html',
3685
+ preview: true
3686
+ })
3687
+
3688
+ // Probably best to place this in a separate file, but for the sake of simplicity we'll keep it here
3689
+ export function MaterialSymbolsSettingsOutline(props: SVGProps<SVGSVGElement>) {
3690
+ return (
3691
+ <svg
3692
+ xmlns="http://www.w3.org/2000/svg"
3693
+ width="1em"
3694
+ height="1em"
3695
+ viewBox="0 0 24 24"
3696
+ {...props}
3697
+ >
3698
+ {/* Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE */}
3699
+ <path
3700
+ fill="currentColor"
3701
+ d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zM11 20h1.975l.35-2.65q.775-.2 1.438-.587t1.212-.938l2.475 1.025l.975-1.7l-2.15-1.625q.125-.35.175-.737T17.5 12t-.05-.787t-.175-.738l2.15-1.625l-.975-1.7l-2.475 1.05q-.55-.575-1.212-.962t-1.438-.588L13 4h-1.975l-.35 2.65q-.775.2-1.437.588t-1.213.937L5.55 7.15l-.975 1.7l2.15 1.6q-.125.375-.175.75t-.05.8q0 .4.05.775t.175.75l-2.15 1.625l.975 1.7l2.475-1.05q.55.575 1.213.963t1.437.587zm1.05-4.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5M12 12"
3702
+ />
3703
+ </svg>
3704
+ )
3705
+ }
3706
+ ```
3707
+
3708
+ Use the SiteLayout type in app/layout.tsx and render shared header and footer content around all pages.
3709
+
3710
+ File: app/layout.tsx
3711
+ ```tsx
3712
+ import {cms} from '@/cms'
3713
+ import {SiteFooter, SiteHeader} from '@/entries/settings/SiteLayout'
3714
+ import {SiteLayout} from '@/entries/settings/SiteLayout.schema'
3715
+
3716
+ export default async function RootLayout({children}: {children: React.ReactNode}) {
3717
+ const settings = await cms.get({
3718
+ root: cms.workspaces.main.settings,
3719
+ type: SiteLayout
3720
+ })
3721
+
3722
+ return (
3723
+ <html lang='en'>
3724
+ <body>
3725
+ <SiteHeader settings={settings} />
3726
+ {children}
3727
+ <SiteFooter settings={settings} />
3728
+ <cms.previews widget />
3729
+ </body>
3730
+ </html>
3731
+ )
3732
+ }
3733
+ ```
3734
+
3735
+ Note (info): Upon completion of these steps, the website is extended with these features:
3736
+
3737
+ - We defined an additional root in our CMS dashboard, this root holds a global settings entry.
3738
+ - We demonstrated how to use custom icons in the CMS dashboard.
3739
+ - The global settings are fetched from the root layout and used to display a header/footer on any website page.
3740
+
3741
+ ### Step 4: Blog (/docs/tutorial/step-4-blog)
3742
+ In this step we keep the landing page, blocks, and shared settings root from step 3, then add a dedicated blog section. The blog overview is fixed to /blog and contains nested post pages with editable slugs.
3743
+
3744
+ Image: step4_a (/step4-a)
3745
+
3746
+ Image: step4_b (/step4-b)
3747
+
3748
+ File: project structure
3749
+ ```
3750
+ app/
3751
+ ├ page.tsx
3752
+ ├ layout.tsx
3753
+ ├ blog/page.tsx
3754
+ ╰ blog/[slug]/page.tsx
3755
+
3756
+ entries/
3757
+ ├ landing/
3758
+ │ ├ LandingPage.tsx
3759
+ │ ╰ LandingPage.schema.tsx
3760
+ ├ blog/
3761
+ │ ├ Blog.tsx
3762
+ │ ╰ Blog.schema.tsx
3763
+ ├ post/
3764
+ │ ├ Post.tsx
3765
+ │ ╰ Post.schema.tsx
3766
+ ╰ settings/
3767
+ ├ SiteLayout.tsx
3768
+ ╰ SiteLayout.schema.tsx
3769
+
3770
+ blocks/
3771
+ ╰ ...
3772
+
3773
+ cms.tsx
3774
+ ```
3775
+
3776
+ Start by defining the blog overview entry schema.
3777
+
3778
+ File: entries/blog/Blog.schema.tsx
3779
+ ```tsx
3780
+ import {Config, Field} from 'alinea'
3781
+
3782
+ export const Blog = Config.document('Blog page', {
3783
+ contains: ['Post'],
3784
+ fields: {
3785
+ title: Field.text('Title', {required: true, width: 0.5}),
3786
+ path: Field.path('Path', {
3787
+ readOnly: true,
3788
+ initialValue: 'blog',
3789
+ width: 0.5
3790
+ }),
3791
+ intro: Field.text('Intro', {multiline: true})
3792
+ }
3793
+ })
3794
+ ```
3795
+
3796
+ Then implement the blog overview server component. It fetches the blog entry and its direct post children in one query, and exposes generateMetadata.
3797
+
3798
+ File: entries/blog/Blog.tsx
3799
+ ```tsx
3800
+ import {Query} from 'alinea'
3801
+ import {Entry} from 'alinea/core/Entry'
3802
+ import type {Metadata} from 'next'
3803
+ import {notFound} from 'next/navigation'
3804
+ import Link from 'next/link'
3805
+ import {cms} from '@/cms'
3806
+ import {Post} from '@/entries/post/Post.schema'
3807
+ import {Blog} from './Blog.schema'
3808
+
3809
+ type PostLink = {id: string; title: string; url: string}
3810
+
3811
+ export async function BlogView() {
3812
+ const page = await cms.get({
3813
+ url: '/blog',
3814
+ type: Blog,
3815
+ select: {
3816
+ title: Blog.title,
3817
+ intro: Blog.intro,
3818
+ posts: Query.children({
3819
+ type: Post,
3820
+ select: {
3821
+ id: Entry.id,
3822
+ title: Entry.title,
3823
+ url: Entry.url
3824
+ }
3825
+ })
3826
+ }
3827
+ })
3828
+ if (!page) notFound()
3829
+
3830
+ return (
3831
+ <main>
3832
+ <h1>{page.title}</h1>
3833
+ {page.intro && <p>{page.intro}</p>}
3834
+ <ul>
3835
+ {page.posts.map((post: PostLink) => (
3836
+ <li key={post.id}>
3837
+ <Link href={post.url}>{post.title}</Link>
3838
+ </li>
3839
+ ))}
3840
+ </ul>
3841
+ </main>
3842
+ )
3843
+ }
3844
+
3845
+ export async function generateMetadata(): Promise<Metadata> {
3846
+ const page = await cms.get({url: '/blog', type: Blog})
3847
+ if (!page) return {}
3848
+
3849
+ return {
3850
+ title: page.metadata.title || page.title,
3851
+ description: page.metadata?.description || page.intro,
3852
+ openGraph: {
3853
+ title: page.metadata.openGraph.title || page.metadata.title || page.title,
3854
+ description:
3855
+ page.metadata.openGraph.description ||
3856
+ page.metadata?.description ||
3857
+ page.intro,
3858
+ images: page.metadata?.openGraph.image
3859
+ ? [page.metadata?.openGraph.image.src]
3860
+ : undefined
3861
+ }
3862
+ }
3863
+ }
3864
+ ```
3865
+
3866
+ Define the post entry schema in its own folder.
3867
+
3868
+ File: entries/post/Post.schema.tsx
3869
+ ```tsx
3870
+ import {Config, Field} from 'alinea'
3871
+
3872
+ export const Post = Config.document('Post page', {
3873
+ fields: {
3874
+ title: Field.text('Title', {required: true, width: 0.5}),
3875
+ path: Field.path('Path', {required: true, width: 0.5}),
3876
+ excerpt: Field.text('Excerpt', {multiline: true}),
3877
+ body: Field.richText('Body')
3878
+ }
3879
+ })
3880
+ ```
3881
+
3882
+ Implement the post detail server component with previous/next sibling navigation and a post metadata helper.
3883
+
3884
+ File: entries/post/Post.tsx
3885
+ ```tsx
3886
+ import {Entry} from 'alinea/core/Entry'
3887
+ import type {TextDoc} from 'alinea'
3888
+ import {Node} from 'alinea/core/TextDoc'
3889
+ import {RichText} from 'alinea/ui'
3890
+ import type {Metadata} from 'next'
3891
+ import {notFound} from 'next/navigation'
3892
+ import Link from 'next/link'
3893
+ import {cms} from '@/cms'
3894
+ import {Post} from './Post.schema'
3895
+
3896
+ type PostLink = {id: string; title: string; url: string; path: string}
3897
+
3898
+ export async function PostView({slug}: {slug: string}) {
3899
+ const post = await cms.get({url: `/blog/${slug}`, type: Post})
3900
+ if (!post) notFound()
3901
+
3902
+ const blogPage = await cms.get({url: '/blog'})
3903
+ if (!blogPage) notFound()
3904
+
3905
+ const siblings = await cms.find({
3906
+ parentId: blogPage._id,
3907
+ select: {
3908
+ id: Entry.id,
3909
+ title: Entry.title,
3910
+ url: Entry.url,
3911
+ path: Entry.path
3912
+ }
3913
+ })
3914
+
3915
+ const index = siblings.findIndex(candidate => candidate.path === slug)
3916
+ const previousPost: PostLink | null = index > 0 ? siblings[index - 1] : null
3917
+ const nextPost: PostLink | null =
3918
+ index >= 0 && index < siblings.length - 1 ? siblings[index + 1] : null
3919
+
3920
+ return (
3921
+ <article>
3922
+ <h1>{post.title}</h1>
3923
+ {typeof post.body === 'string' ? <p>{post.body}</p> : <RichText doc={post.body} />}
3924
+ <p>
3925
+ <Link href="/blog">← Back to the full blog archive</Link>
3926
+ </p>
3927
+ {(previousPost || nextPost) && (
3928
+ <nav aria-label="Post navigation">
3929
+ <h2>Next/Previous blogpost</h2>
3930
+ <ul>
3931
+ {previousPost && (
3932
+ <li>
3933
+ <Link href={previousPost.url}>
3934
+ Previous: {previousPost.title}
3935
+ </Link>
3936
+ </li>
3937
+ )}
3938
+ {nextPost && (
3939
+ <li>
3940
+ <Link href={nextPost.url}>Next: {nextPost.title}</Link>
3941
+ </li>
3942
+ )}
3943
+ </ul>
3944
+ </nav>
3945
+ )}
3946
+ </article>
3947
+ )
3948
+ }
3949
+
3950
+ export async function generatePostMetadata(slug: string): Promise<Metadata> {
3951
+ const post = await cms.get({url: `/blog/${slug}`, type: Post})
3952
+ if (!post) return {}
3953
+
3954
+ const bodyText = plainText(post.body)
3955
+
3956
+ return {
3957
+ title: post.metadata.title || post.title,
3958
+ description: post.metadata?.description || post.excerpt || bodyText,
3959
+ openGraph: {
3960
+ title: post.metadata.openGraph.title || post.metadata.title || post.title,
3961
+ description:
3962
+ post.metadata.openGraph.description ||
3963
+ post.metadata?.description ||
3964
+ post.excerpt ||
3965
+ bodyText,
3966
+ images: post.metadata?.openGraph.image
3967
+ ? [post.metadata?.openGraph.image.src]
3968
+ : undefined
3969
+ }
3970
+ }
3971
+ }
3972
+
3973
+ function plainText(value: TextDoc<any> | string | undefined): string {
3974
+ if (!value) return ''
3975
+ if (typeof value === 'string') return value
3976
+
3977
+ if (!Array.isArray(value)) return ''
3978
+ const result = value
3979
+ .reduce((acc, node) => {
3980
+ return acc + textOf(node)
3981
+ }, '')
3982
+ .trim()
3983
+ return result.replace(/ +(?= )/g, '')
3984
+ }
3985
+
3986
+ function textOf(node: Node): string {
3987
+ if (node._type === 'hardBreak') return '\n'
3988
+ if (Node.isText(node)) {
3989
+ return node.text ? ' ' + node.text : ''
3990
+ } else if (Node.isElement(node) && node.content) {
3991
+ return node.content.reduce((acc, node) => {
3992
+ return acc + textOf(node)
3993
+ }, '')
3994
+ }
3995
+ return ''
3996
+ }
3997
+ ```
3998
+
3999
+ Register Blog and Post in cms.tsx and seed the fixed /blog overview page under the Pages root.
4000
+
4001
+ File: cms.tsx
4002
+ ```tsx
4003
+ import {Blog} from '@/entries/blog/Blog.schema'
4004
+ import {LandingPage} from '@/entries/landing/LandingPage.schema'
4005
+ import {Post} from '@/entries/post/Post.schema'
4006
+ import {SiteLayout} from '@/entries/settings/SiteLayout.schema'
4007
+
4008
+ export const cms = createCMS({
4009
+ schema: {
4010
+ LandingPage,
4011
+ SiteLayout,
4012
+ Blog,
4013
+ Post
4014
+ },
4015
+ workspaces: {
4016
+ main: Config.workspace('Main', {
4017
+ roots: {
4018
+ pages: Config.root('Pages', {
4019
+ contains: ['LandingPage', 'Blog'],
4020
+ children: {
4021
+ index: Config.page({
4022
+ type: LandingPage,
4023
+ fields: {
4024
+ title: 'Welcome',
4025
+ path: ''
4026
+ }
4027
+ }),
4028
+ blog: Config.page({
4029
+ type: Blog,
4030
+ fields: {
4031
+ title: 'Blog',
4032
+ path: 'blog',
4033
+ intro: 'Latest posts'
4034
+ }
4035
+ })
4036
+ }
4037
+ })
4038
+ }
4039
+ })
4040
+ }
4041
+ })
4042
+ ```
4043
+
4044
+ Add dedicated Next.js routes for /blog and /blog/[slug]. Keep these routes thin and delegate rendering + metadata to entry components.
4045
+
4046
+ File: app/blog/page.tsx
4047
+ ```tsx
4048
+ import {BlogView} from '@/entries/blog/Blog'
4049
+
4050
+ export {generateMetadata} from '@/entries/blog/Blog'
4051
+
4052
+ export default function BlogRoute() {
4053
+ return <BlogView />
4054
+ }
4055
+ ```
4056
+
4057
+ File: app/blog/[slug]/page.tsx
4058
+ ```tsx
4059
+ import {Entry} from 'alinea/core/Entry'
4060
+ import type {Metadata} from 'next'
4061
+ import {cms} from '@/cms'
4062
+ import {generatePostMetadata, PostView} from '@/entries/post/Post'
4063
+ import {Post} from '@/entries/post/Post.schema'
4064
+
4065
+ interface PostRouteProps {
4066
+ params: Promise<{slug: string}>
4067
+ }
4068
+
4069
+ export async function generateStaticParams() {
4070
+ const paths = await cms.find({
4071
+ type: Post,
4072
+ select: Entry.path
4073
+ })
4074
+
4075
+ return paths.map(slug => ({slug}))
4076
+ }
4077
+
4078
+ export async function generateMetadata({
4079
+ params
4080
+ }: PostRouteProps): Promise<Metadata> {
4081
+ const {slug} = await params
4082
+ return generatePostMetadata(slug)
4083
+ }
4084
+
4085
+ export default async function BlogPostRoute({params}: PostRouteProps) {
4086
+ const {slug} = await params
4087
+ return <PostView slug={slug} />
4088
+ }
4089
+ ```
4090
+
4091
+ Note (info): Upon completion of these steps, your website should be extended with these features:
4092
+
4093
+ - The landing page from step 3 keeps working with text, image, and weather blocks.
4094
+ - A fixed /blog overview page is seeded and available right away.
4095
+ - Individual blog post pages support previous/next navigation between sibling posts.
4096
+ - Metadata generation is implemented for the blog listing and post detail routes.
4097
+
4098
+ ### Step 5: Catch-all route (/docs/tutorial/step-5-catch-all)
4099
+ In this step we replace dedicated routes with one catch-all route, which gives full freedom to content editors to create and maintain their own content structures. All pages are served via one route.
4100
+
4101
+ Image: step5 (/step5)
4102
+
4103
+ We keep the Blog/Post setup from the previous step and add recursive Page entries for regular pages. We get rid of the current page.tsx routes for the landing page, the blog and invidivual posts and replace them all with one new route.
4104
+
4105
+ File: project structure
4106
+ ```
4107
+ app/
4108
+ ├ layout.tsx
4109
+ ╰ [[...slug]]/page.tsx
4110
+
4111
+ entries/
4112
+ ├ page/
4113
+ │ ├ Page.tsx
4114
+ │ ╰ Page.schema.tsx
4115
+ ├ blog/
4116
+ │ ├ Blog.tsx
4117
+ │ ╰ Blog.schema.tsx
4118
+ ├ post/
4119
+ │ ├ Post.tsx
4120
+ │ ╰ Post.schema.tsx
4121
+ ╰ settings/
4122
+ ├ SiteLayout.tsx
4123
+ ╰ SiteLayout.schema.tsx
4124
+
4125
+ blocks/
4126
+ ╰ ...
4127
+
4128
+ cms.tsx
4129
+ ```
4130
+
4131
+ Define a recursive Page entry type so editors can freely nest regular pages under one another, such as an About page with Team and History underneath it. You can define which page types can be nested by using the contains property.
4132
+
4133
+ File: entries/page/Page.schema.tsx
4134
+ ```tsx
4135
+ import {Config, Field} from 'alinea'
4136
+ import {GalleryBlock} from '@/blocks/gallery/GalleryBlock.schema'
4137
+ import {ImageBlock} from '@/blocks/image/ImageBlock.schema'
4138
+ import {TextBlock} from '@/blocks/text/TextBlock.schema'
4139
+
4140
+ export const Page = Config.document('Page', {
4141
+ contains: ['Page'],
4142
+ fields: {
4143
+ title: Field.text('Title', {required: true, width: 0.5}),
4144
+ path: Field.path('Path', {required: true, width: 0.5}),
4145
+ blocks: Field.list('Blocks', {
4146
+ schema: {
4147
+ TextBlock,
4148
+ ImageBlock,
4149
+ GalleryBlock
4150
+ }
4151
+ })
4152
+ }
4153
+ })
4154
+ ```
4155
+
4156
+ Make sure to register this new type in cms.tsx. Regular pages can then be created freely in the Pages tree. We did opt to also go for one shared generateMetadata function, since all pages share the same metadata fields. In a real applications you might want to define different logic per page type, do define custom fallback metadata depending on the type of page.
4157
+
4158
+ File: cms.tsx
4159
+ ```tsx
4160
+ import {Blog} from '@/entries/blog/Blog.schema'
4161
+ import {Page} from '@/entries/page/Page.schema'
4162
+ import {Post} from '@/entries/post/Post.schema'
4163
+ import {SiteLayout} from '@/entries/settings/SiteLayout.schema'
4164
+
4165
+ export const cms = createCMS({
4166
+ schema: {
4167
+ Page,
4168
+ SiteLayout,
4169
+ Blog,
4170
+ Post
4171
+ },
4172
+ workspaces: {
4173
+ main: Config.workspace('Main', {
4174
+ roots: {
4175
+ pages: Config.root('Pages', {
4176
+ contains: ['Page', 'Blog'],
4177
+ children: {
4178
+ blog: Config.page({
4179
+ type: Blog,
4180
+ fields: {title: 'Blog', path: 'blog', intro: 'Latest posts'}
4181
+ })
4182
+ }
4183
+ }),
4184
+ settings: Config.root('Settings', {
4185
+ contains: ['SiteLayout']
4186
+ })
4187
+ }
4188
+ })
4189
+ }
4190
+ })
4191
+ ```
4192
+
4193
+ Use a catch-all route that resolves the URL from slug segments and branches on page._type. Page rendering stay inside their own server components, which nicely isolates functionality per page type. This one route replaces app/page.tsx, app/blog/page.tsx, and app/blog/[slug]/page.tsx.
4194
+
4195
+ File: app/[[...slug]]/page.tsx
4196
+ ```tsx
4197
+ import type {Infer} from 'alinea'
4198
+ import {Entry} from 'alinea/core/Entry'
4199
+ import type {Metadata} from 'next'
4200
+ import {notFound} from 'next/navigation'
4201
+ import {cms} from '@/cms'
4202
+ import {BlogView} from '@/entries/blog/Blog'
4203
+ import {PageView} from '@/entries/page/Page'
4204
+ import {Page} from '@/entries/page/Page.schema'
4205
+ import {PostView} from '@/entries/post/Post'
4206
+
4207
+ interface RouteProps {
4208
+ params: Promise<{slug?: Array<string>}>
4209
+ }
4210
+
4211
+ export async function generateStaticParams() {
4212
+ const urls = await cms.find({
4213
+ root: cms.workspaces.main.pages,
4214
+ select: Entry.url
4215
+ })
4216
+
4217
+ return urls.map(url => ({slug: url === '/' ? [] : url.slice(1).split('/')}))
4218
+ }
4219
+
4220
+ export async function generateMetadata({
4221
+ params
4222
+ }: RouteProps): Promise<Metadata> {
4223
+ const {slug = []} = await params
4224
+ const url = slug.length > 0 ? `/${slug.join('/')}` : '/'
4225
+ const page = await cms.get({
4226
+ url,
4227
+ include: {
4228
+ title: Entry.title,
4229
+ metadata: Page.metadata // We will introduce Entry.metadata shortly, for now use Page or any other document type
4230
+ }
4231
+ })
4232
+ if (!page) return {}
4233
+
4234
+ return {
4235
+ title: page.metadata?.title || page.title,
4236
+ description: page.metadata?.description,
4237
+ openGraph: {
4238
+ title:
4239
+ page.metadata?.openGraph?.title || page.metadata?.title || page.title,
4240
+ description:
4241
+ page.metadata?.openGraph?.description || page.metadata?.description,
4242
+ images: page.metadata?.openGraph?.image
4243
+ ? [page.metadata.openGraph.image.src]
4244
+ : undefined
4245
+ }
4246
+ }
4247
+ }
4248
+
4249
+ export default async function CatchAllPage({params}: RouteProps) {
4250
+ const {slug = []} = await params
4251
+ const url = slug.length > 0 ? `/${slug.join('/')}` : '/'
4252
+ const page = await cms.get({url})
4253
+
4254
+ if (!page) notFound()
4255
+
4256
+ if (page._type === 'Blog') {
4257
+ return <BlogView />
4258
+ }
4259
+
4260
+ if (page._type === 'Post') {
4261
+ const postSlug = slug[slug.length - 1]
4262
+ if (!postSlug) notFound()
4263
+ return <PostView slug={postSlug} />
4264
+ }
4265
+
4266
+ const regularPage = await cms.get({url, type: Page})
4267
+ if (!regularPage) notFound()
4268
+ return <PageView page={regularPage} />
4269
+ }
4270
+ ```
4271
+
4272
+ Note (info): Upon completion of these steps, your website should be extended with these features:
4273
+
4274
+ - A single catch-all route resolves all page URLs in the Pages root, including nested regular pages.
4275
+ - Regular pages are modeled with recursive Page entries, for example an "About"-page with Team and History underneath it.
4276
+ - The Blog/Post flow from step 4 still works, including post navigation and the back-to-blog CTA.
4277
+
4278
+ Note (warning): It's worth mentioning that a catch-all route is not always the preferred way of working. It improves the flexibility of your website but it makes it impossible for Next.js to have a per-page javascript-bundle, so it might make the initial page loads of your website a bit heavier. Go for the solution that best fits your specific needs.