@imjp/writenex-astro 1.5.0 → 1.6.1
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.
- package/README.md +894 -60
- package/package.json +1 -1
- package/src/fields/README.md +985 -0
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ Visual editor for Astro content collections - WYSIWYG editing for your Astro sit
|
|
|
8
8
|
|
|
9
9
|
### Key Features
|
|
10
10
|
|
|
11
|
+
- **Fields API** - TypeScript-first builder pattern with 25+ field types
|
|
11
12
|
- **Zero Config** - Auto-discovers your content collections from `src/content/`
|
|
12
13
|
- **WYSIWYG Editor** - MDXEditor-powered markdown editing with live preview
|
|
13
14
|
- **Smart Schema Detection** - Automatically infers frontmatter schema from existing content
|
|
@@ -20,7 +21,6 @@ Visual editor for Astro content collections - WYSIWYG editing for your Astro sit
|
|
|
20
21
|
- **Search & Filter** - Find content quickly with search and draft filters
|
|
21
22
|
- **Preview Links** - Quick access to preview your content in the browser
|
|
22
23
|
- **Production Safe** - Disabled by default in production builds
|
|
23
|
-
- **Version History** - Automatic shadow copies with restore capability
|
|
24
24
|
|
|
25
25
|
## Quick Start
|
|
26
26
|
|
|
@@ -77,51 +77,54 @@ export default defineConfig({
|
|
|
77
77
|
|
|
78
78
|
By default, Writenex auto-discovers your content collections from `src/content/` and infers the frontmatter schema from existing files. No configuration needed for most projects.
|
|
79
79
|
|
|
80
|
-
### Custom Configuration
|
|
80
|
+
### Custom Configuration with Fields API
|
|
81
81
|
|
|
82
82
|
Create `writenex.config.ts` in your project root for full control:
|
|
83
83
|
|
|
84
84
|
```typescript
|
|
85
85
|
// writenex.config.ts
|
|
86
|
-
import { defineConfig } from "@imjp/writenex-astro";
|
|
86
|
+
import { defineConfig, collection, fields } from "@imjp/writenex-astro";
|
|
87
87
|
|
|
88
88
|
export default defineConfig({
|
|
89
|
-
// Define collections explicitly
|
|
90
89
|
collections: [
|
|
91
|
-
{
|
|
90
|
+
collection({
|
|
92
91
|
name: "blog",
|
|
93
92
|
path: "src/content/blog",
|
|
94
93
|
filePattern: "{slug}.md",
|
|
95
94
|
previewUrl: "/blog/{slug}",
|
|
96
95
|
schema: {
|
|
97
|
-
title: {
|
|
98
|
-
description: {
|
|
99
|
-
pubDate: {
|
|
100
|
-
updatedDate: {
|
|
101
|
-
heroImage: {
|
|
102
|
-
tags: {
|
|
103
|
-
draft: {
|
|
96
|
+
title: fields.text({ label: "Title", validation: { isRequired: true } }),
|
|
97
|
+
description: fields.text({ label: "Description", multiline: true }),
|
|
98
|
+
pubDate: fields.date({ label: "Published Date", validation: { isRequired: true } }),
|
|
99
|
+
updatedDate: fields.datetime({ label: "Last Updated" }),
|
|
100
|
+
heroImage: fields.image({ label: "Hero Image" }),
|
|
101
|
+
tags: fields.multiselect({ label: "Tags", options: ["javascript", "typescript", "react", "astro"] }),
|
|
102
|
+
draft: fields.checkbox({ label: "Draft", defaultValue: true }),
|
|
103
|
+
body: fields.mdx({ label: "Content", validation: { isRequired: true } }),
|
|
104
104
|
},
|
|
105
|
-
},
|
|
106
|
-
{
|
|
105
|
+
}),
|
|
106
|
+
collection({
|
|
107
107
|
name: "docs",
|
|
108
108
|
path: "src/content/docs",
|
|
109
109
|
filePattern: "{slug}.md",
|
|
110
110
|
previewUrl: "/docs/{slug}",
|
|
111
|
-
},
|
|
111
|
+
}),
|
|
112
112
|
],
|
|
113
113
|
|
|
114
|
-
// Image upload settings
|
|
115
114
|
images: {
|
|
116
|
-
strategy: "colocated",
|
|
117
|
-
publicPath: "/images",
|
|
118
|
-
storagePath: "public/images",
|
|
115
|
+
strategy: "colocated",
|
|
116
|
+
publicPath: "/images",
|
|
117
|
+
storagePath: "public/images",
|
|
119
118
|
},
|
|
120
119
|
|
|
121
|
-
// Editor settings
|
|
122
120
|
editor: {
|
|
123
121
|
autosave: true,
|
|
124
|
-
autosaveInterval: 3000,
|
|
122
|
+
autosaveInterval: 3000,
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
versionHistory: {
|
|
126
|
+
enabled: true,
|
|
127
|
+
maxVersions: 20,
|
|
125
128
|
},
|
|
126
129
|
});
|
|
127
130
|
```
|
|
@@ -135,12 +138,850 @@ export default defineConfig({
|
|
|
135
138
|
```typescript
|
|
136
139
|
// astro.config.mjs
|
|
137
140
|
writenex({
|
|
138
|
-
allowProduction: false,
|
|
141
|
+
allowProduction: false,
|
|
139
142
|
});
|
|
140
143
|
```
|
|
141
144
|
|
|
142
145
|
The editor is always available at `/_writenex` during development.
|
|
143
146
|
|
|
147
|
+
## Fields API
|
|
148
|
+
|
|
149
|
+
The Fields API provides a TypeScript-first builder pattern for defining content schema fields.
|
|
150
|
+
|
|
151
|
+
### Imports
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import { defineConfig, collection, singleton, fields } from "@imjp/writenex-astro/config";
|
|
155
|
+
// or
|
|
156
|
+
import { defineConfig, collection, singleton, fields } from "@writenex/astro/config";
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### collection() vs singleton()
|
|
160
|
+
|
|
161
|
+
- **`collection()`** - For multi-item content (blog posts, docs, products)
|
|
162
|
+
- **`singleton()`** - For single-item content (site settings, about page)
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
// Multi-item collection
|
|
166
|
+
collection({
|
|
167
|
+
name: "blog",
|
|
168
|
+
path: "src/content/blog",
|
|
169
|
+
schema: { /* field definitions */ }
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// Single-item singleton
|
|
173
|
+
singleton({
|
|
174
|
+
name: "settings",
|
|
175
|
+
path: "src/content/settings.json",
|
|
176
|
+
schema: { /* field definitions */ }
|
|
177
|
+
})
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Field Types
|
|
181
|
+
|
|
182
|
+
### Text Fields
|
|
183
|
+
|
|
184
|
+
#### `fields.text()`
|
|
185
|
+
|
|
186
|
+
Single or multi-line text input.
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
fields.text({ label: "Title" })
|
|
190
|
+
fields.text({ label: "Description", multiline: true })
|
|
191
|
+
fields.text({
|
|
192
|
+
label: "Bio",
|
|
193
|
+
multiline: true,
|
|
194
|
+
placeholder: "Tell us about yourself...",
|
|
195
|
+
validation: {
|
|
196
|
+
isRequired: true,
|
|
197
|
+
minLength: 10,
|
|
198
|
+
maxLength: 500
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
| Option | Type | Description |
|
|
204
|
+
|--------|------|-------------|
|
|
205
|
+
| `label` | `string` | Display label |
|
|
206
|
+
| `description` | `string` | Help text |
|
|
207
|
+
| `multiline` | `boolean` | Multi-line textarea (default: false) |
|
|
208
|
+
| `placeholder` | `string` | Placeholder text |
|
|
209
|
+
| `defaultValue` | `string` | Default value |
|
|
210
|
+
| `validation.isRequired` | `boolean` | Field is required |
|
|
211
|
+
| `validation.minLength` | `number` | Minimum character count |
|
|
212
|
+
| `validation.maxLength` | `number` | Maximum character count |
|
|
213
|
+
| `validation.pattern` | `string` | Regex pattern |
|
|
214
|
+
| `validation.patternDescription` | `string` | Pattern error message |
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
#### `fields.slug()`
|
|
219
|
+
|
|
220
|
+
URL-friendly slug field with auto-generation support.
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
fields.slug({ label: "URL Slug" })
|
|
224
|
+
fields.slug({
|
|
225
|
+
name: { label: "Name Slug", placeholder: "my-page" },
|
|
226
|
+
pathname: { label: "URL Path", placeholder: "/pages/" }
|
|
227
|
+
})
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
| Option | Type | Description |
|
|
231
|
+
|--------|------|-------------|
|
|
232
|
+
| `label` | `string` | Display label |
|
|
233
|
+
| `name.label` | `string` | Name field label |
|
|
234
|
+
| `name.placeholder` | `string` | Name placeholder |
|
|
235
|
+
| `pathname.label` | `string` | Path field label |
|
|
236
|
+
| `pathname.placeholder` | `string` | Path placeholder |
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
#### `fields.url()`
|
|
241
|
+
|
|
242
|
+
URL input with validation.
|
|
243
|
+
|
|
244
|
+
```typescript
|
|
245
|
+
fields.url({ label: "Website" })
|
|
246
|
+
fields.url({
|
|
247
|
+
label: "GitHub Profile",
|
|
248
|
+
placeholder: "https://github.com/username",
|
|
249
|
+
validation: { isRequired: true }
|
|
250
|
+
})
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
| Option | Type | Description |
|
|
254
|
+
|--------|------|-------------|
|
|
255
|
+
| `label` | `string` | Display label |
|
|
256
|
+
| `placeholder` | `string` | Placeholder URL |
|
|
257
|
+
| `validation.isRequired` | `boolean` | Field is required |
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
### Number Fields
|
|
262
|
+
|
|
263
|
+
#### `fields.number()`
|
|
264
|
+
|
|
265
|
+
Numeric input for decimals.
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
fields.number({ label: "Price" })
|
|
269
|
+
fields.number({
|
|
270
|
+
label: "Rating",
|
|
271
|
+
placeholder: 4.5,
|
|
272
|
+
validation: { min: 0, max: 5 }
|
|
273
|
+
})
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
| Option | Type | Description |
|
|
277
|
+
|--------|------|-------------|
|
|
278
|
+
| `label` | `string` | Display label |
|
|
279
|
+
| `placeholder` | `number` | Placeholder value |
|
|
280
|
+
| `defaultValue` | `number` | Default value |
|
|
281
|
+
| `validation.isRequired` | `boolean` | Field is required |
|
|
282
|
+
| `validation.min` | `number` | Minimum value |
|
|
283
|
+
| `validation.max` | `number` | Maximum value |
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
#### `fields.integer()`
|
|
288
|
+
|
|
289
|
+
Whole number input.
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
fields.integer({ label: "Quantity" })
|
|
293
|
+
fields.integer({
|
|
294
|
+
label: "Year",
|
|
295
|
+
validation: { min: 1900, max: 2100 }
|
|
296
|
+
})
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
| Option | Type | Description |
|
|
300
|
+
|--------|------|-------------|
|
|
301
|
+
| `label` | `string` | Display label |
|
|
302
|
+
| `placeholder` | `number` | Placeholder value |
|
|
303
|
+
| `defaultValue` | `number` | Default value |
|
|
304
|
+
| `validation.isRequired` | `boolean` | Field is required |
|
|
305
|
+
| `validation.min` | `number` | Minimum value |
|
|
306
|
+
| `validation.max` | `number` | Maximum value |
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
310
|
+
### Selection Fields
|
|
311
|
+
|
|
312
|
+
#### `fields.select()`
|
|
313
|
+
|
|
314
|
+
Dropdown selection from options.
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
fields.select({
|
|
318
|
+
label: "Status",
|
|
319
|
+
options: ["draft", "published", "archived"],
|
|
320
|
+
defaultValue: "draft"
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
fields.select({
|
|
324
|
+
label: "Category",
|
|
325
|
+
options: ["technology", "lifestyle", "travel"],
|
|
326
|
+
validation: { isRequired: true }
|
|
327
|
+
})
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
| Option | Type | Description |
|
|
331
|
+
|--------|------|-------------|
|
|
332
|
+
| `label` | `string` | Display label |
|
|
333
|
+
| `options` | `string[]` | Selectable options (required) |
|
|
334
|
+
| `defaultValue` | `string` | Default option |
|
|
335
|
+
| `validation.isRequired` | `boolean` | Field is required |
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
#### `fields.multiselect()`
|
|
340
|
+
|
|
341
|
+
Multi-select with checkboxes or multi-select UI.
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
fields.multiselect({
|
|
345
|
+
label: "Tags",
|
|
346
|
+
options: ["javascript", "typescript", "react", "node"],
|
|
347
|
+
defaultValue: ["javascript"]
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
fields.multiselect({
|
|
351
|
+
label: "Topics",
|
|
352
|
+
options: ["frontend", "backend", "devops", "mobile"],
|
|
353
|
+
validation: { isRequired: true }
|
|
354
|
+
})
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
| Option | Type | Description |
|
|
358
|
+
|--------|------|-------------|
|
|
359
|
+
| `label` | `string` | Display label |
|
|
360
|
+
| `options` | `string[]` | Selectable options (required) |
|
|
361
|
+
| `defaultValue` | `string[]` | Default selections |
|
|
362
|
+
| `validation.isRequired` | `boolean` | Field is required |
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
#### `fields.checkbox()`
|
|
367
|
+
|
|
368
|
+
Boolean toggle.
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
fields.checkbox({ label: "Published" })
|
|
372
|
+
fields.checkbox({
|
|
373
|
+
label: "Featured",
|
|
374
|
+
defaultValue: false
|
|
375
|
+
})
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
| Option | Type | Description |
|
|
379
|
+
|--------|------|-------------|
|
|
380
|
+
| `label` | `string` | Display label |
|
|
381
|
+
| `defaultValue` | `boolean` | Default state (default: false) |
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
### Date & Time Fields
|
|
386
|
+
|
|
387
|
+
#### `fields.date()`
|
|
388
|
+
|
|
389
|
+
Date picker.
|
|
390
|
+
|
|
391
|
+
```typescript
|
|
392
|
+
fields.date({ label: "Published Date" })
|
|
393
|
+
fields.date({
|
|
394
|
+
label: "Event Date",
|
|
395
|
+
defaultValue: "2024-01-15"
|
|
396
|
+
})
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
| Option | Type | Description |
|
|
400
|
+
|--------|------|-------------|
|
|
401
|
+
| `label` | `string` | Display label |
|
|
402
|
+
| `defaultValue` | `string` | Default date (YYYY-MM-DD) |
|
|
403
|
+
| `validation.isRequired` | `boolean` | Field is required |
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
#### `fields.datetime()`
|
|
408
|
+
|
|
409
|
+
Date and time picker.
|
|
410
|
+
|
|
411
|
+
```typescript
|
|
412
|
+
fields.datetime({ label: "Publish At" })
|
|
413
|
+
fields.datetime({
|
|
414
|
+
label: "Event Date & Time",
|
|
415
|
+
defaultValue: "2024-01-15T09:00"
|
|
416
|
+
})
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
| Option | Type | Description |
|
|
420
|
+
|--------|------|-------------|
|
|
421
|
+
| `label` | `string` | Display label |
|
|
422
|
+
| `defaultValue` | `string` | Default datetime (ISO format) |
|
|
423
|
+
| `validation.isRequired` | `boolean` | Field is required |
|
|
424
|
+
|
|
425
|
+
---
|
|
426
|
+
|
|
427
|
+
### File & Media Fields
|
|
428
|
+
|
|
429
|
+
#### `fields.image()`
|
|
430
|
+
|
|
431
|
+
Image upload with preview.
|
|
432
|
+
|
|
433
|
+
```typescript
|
|
434
|
+
fields.image({ label: "Hero Image" })
|
|
435
|
+
fields.image({
|
|
436
|
+
label: "Thumbnail",
|
|
437
|
+
directory: "public/images/blog",
|
|
438
|
+
publicPath: "/images/blog"
|
|
439
|
+
})
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
| Option | Type | Description |
|
|
443
|
+
|--------|------|-------------|
|
|
444
|
+
| `label` | `string` | Display label |
|
|
445
|
+
| `directory` | `string` | Storage directory |
|
|
446
|
+
| `publicPath` | `string` | Public URL path |
|
|
447
|
+
| `validation.isRequired` | `boolean` | Field is required |
|
|
448
|
+
|
|
449
|
+
---
|
|
450
|
+
|
|
451
|
+
#### `fields.file()`
|
|
452
|
+
|
|
453
|
+
File upload for documents.
|
|
454
|
+
|
|
455
|
+
```typescript
|
|
456
|
+
fields.file({ label: "Attachment" })
|
|
457
|
+
fields.file({
|
|
458
|
+
label: "PDF Document",
|
|
459
|
+
directory: "public/files",
|
|
460
|
+
publicPath: "/files"
|
|
461
|
+
})
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
| Option | Type | Description |
|
|
465
|
+
|--------|------|-------------|
|
|
466
|
+
| `label` | `string` | Display label |
|
|
467
|
+
| `directory` | `string` | Storage directory |
|
|
468
|
+
| `publicPath` | `string` | Public URL path |
|
|
469
|
+
| `validation.isRequired` | `boolean` | Field is required |
|
|
470
|
+
|
|
471
|
+
---
|
|
472
|
+
|
|
473
|
+
### Structured Fields
|
|
474
|
+
|
|
475
|
+
#### `fields.object()`
|
|
476
|
+
|
|
477
|
+
Nested group of fields.
|
|
478
|
+
|
|
479
|
+
```typescript
|
|
480
|
+
fields.object({
|
|
481
|
+
label: "Author",
|
|
482
|
+
fields: {
|
|
483
|
+
name: fields.text({ label: "Name" }),
|
|
484
|
+
email: fields.url({ label: "Email" }),
|
|
485
|
+
bio: fields.text({ label: "Bio", multiline: true }),
|
|
486
|
+
}
|
|
487
|
+
})
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
| Option | Type | Description |
|
|
491
|
+
|--------|------|-------------|
|
|
492
|
+
| `label` | `string` | Display label |
|
|
493
|
+
| `fields` | `Record<string, FieldDefinition>` | Nested fields (required) |
|
|
494
|
+
| `validation.isRequired` | `boolean` | Field is required |
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
#### `fields.array()`
|
|
499
|
+
|
|
500
|
+
List of items with the same schema.
|
|
501
|
+
|
|
502
|
+
```typescript
|
|
503
|
+
fields.array({
|
|
504
|
+
label: "Tags",
|
|
505
|
+
itemField: fields.text({ label: "Tag" }),
|
|
506
|
+
itemLabel: "Tag"
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
fields.array({
|
|
510
|
+
label: "Links",
|
|
511
|
+
itemField: fields.object({
|
|
512
|
+
fields: {
|
|
513
|
+
title: fields.text({ label: "Title" }),
|
|
514
|
+
url: fields.url({ label: "URL" }),
|
|
515
|
+
}
|
|
516
|
+
}),
|
|
517
|
+
itemLabel: "Link"
|
|
518
|
+
})
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
| Option | Type | Description |
|
|
522
|
+
|--------|------|-------------|
|
|
523
|
+
| `label` | `string` | Display label |
|
|
524
|
+
| `itemField` | `FieldDefinition` | Schema for each item (required) |
|
|
525
|
+
| `itemLabel` | `string` | Label for items in editor |
|
|
526
|
+
| `validation.isRequired` | `boolean` | Field is required |
|
|
527
|
+
|
|
528
|
+
---
|
|
529
|
+
|
|
530
|
+
#### `fields.blocks()`
|
|
531
|
+
|
|
532
|
+
List of items with different block types.
|
|
533
|
+
|
|
534
|
+
```typescript
|
|
535
|
+
fields.blocks({
|
|
536
|
+
label: "Content Blocks",
|
|
537
|
+
blockTypes: {
|
|
538
|
+
paragraph: {
|
|
539
|
+
label: "Paragraph",
|
|
540
|
+
fields: {
|
|
541
|
+
text: fields.text({ label: "Text", multiline: true })
|
|
542
|
+
}
|
|
543
|
+
},
|
|
544
|
+
quote: {
|
|
545
|
+
label: "Quote",
|
|
546
|
+
fields: {
|
|
547
|
+
text: fields.text({ label: "Quote" }),
|
|
548
|
+
attribution: fields.text({ label: "Attribution" })
|
|
549
|
+
}
|
|
550
|
+
},
|
|
551
|
+
image: {
|
|
552
|
+
label: "Image",
|
|
553
|
+
fields: {
|
|
554
|
+
src: fields.image({ label: "Image" }),
|
|
555
|
+
caption: fields.text({ label: "Caption" })
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
},
|
|
559
|
+
itemLabel: "Block"
|
|
560
|
+
})
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
| Option | Type | Description |
|
|
564
|
+
|--------|------|-------------|
|
|
565
|
+
| `label` | `string` | Display label |
|
|
566
|
+
| `blockTypes` | `Record<string, BlockDefinition>` | Block type definitions (required) |
|
|
567
|
+
| `itemLabel` | `string` | Label for blocks in editor |
|
|
568
|
+
|
|
569
|
+
---
|
|
570
|
+
|
|
571
|
+
### Reference Fields
|
|
572
|
+
|
|
573
|
+
#### `fields.relationship()`
|
|
574
|
+
|
|
575
|
+
Reference to another collection item.
|
|
576
|
+
|
|
577
|
+
```typescript
|
|
578
|
+
fields.relationship({
|
|
579
|
+
label: "Author",
|
|
580
|
+
collection: "authors"
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
fields.relationship({
|
|
584
|
+
label: "Related Posts",
|
|
585
|
+
collection: "blog"
|
|
586
|
+
})
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
| Option | Type | Description |
|
|
590
|
+
|--------|------|-------------|
|
|
591
|
+
| `label` | `string` | Display label |
|
|
592
|
+
| `collection` | `string` | Target collection name (required) |
|
|
593
|
+
| `validation.isRequired` | `boolean` | Field is required |
|
|
594
|
+
|
|
595
|
+
---
|
|
596
|
+
|
|
597
|
+
#### `fields.pathReference()`
|
|
598
|
+
|
|
599
|
+
Reference to a file path.
|
|
600
|
+
|
|
601
|
+
```typescript
|
|
602
|
+
fields.pathReference({ label: "Template" })
|
|
603
|
+
fields.pathReference({
|
|
604
|
+
label: "Layout",
|
|
605
|
+
contentTypes: [".astro", ".mdx"]
|
|
606
|
+
})
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
| Option | Type | Description |
|
|
610
|
+
|--------|------|-------------|
|
|
611
|
+
| `label` | `string` | Display label |
|
|
612
|
+
| `contentTypes` | `string[]` | Allowed file extensions |
|
|
613
|
+
|
|
614
|
+
---
|
|
615
|
+
|
|
616
|
+
### Content Fields
|
|
617
|
+
|
|
618
|
+
#### `fields.markdoc()`
|
|
619
|
+
|
|
620
|
+
Markdoc rich content.
|
|
621
|
+
|
|
622
|
+
```typescript
|
|
623
|
+
fields.markdoc({ label: "Content" })
|
|
624
|
+
fields.markdoc({
|
|
625
|
+
label: "Article Body",
|
|
626
|
+
validation: { isRequired: true }
|
|
627
|
+
})
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
---
|
|
631
|
+
|
|
632
|
+
#### `fields.mdx()`
|
|
633
|
+
|
|
634
|
+
MDX content with component support.
|
|
635
|
+
|
|
636
|
+
```typescript
|
|
637
|
+
fields.mdx({ label: "Content" })
|
|
638
|
+
fields.mdx({
|
|
639
|
+
label: "Documentation",
|
|
640
|
+
validation: { isRequired: true }
|
|
641
|
+
})
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
---
|
|
645
|
+
|
|
646
|
+
### Conditional Fields
|
|
647
|
+
|
|
648
|
+
#### `fields.conditional()`
|
|
649
|
+
|
|
650
|
+
Show a field based on another field's value.
|
|
651
|
+
|
|
652
|
+
```typescript
|
|
653
|
+
fields.conditional({
|
|
654
|
+
label: "CTA Button",
|
|
655
|
+
matchField: "hasCTA",
|
|
656
|
+
matchValue: true,
|
|
657
|
+
showField: fields.object({
|
|
658
|
+
fields: {
|
|
659
|
+
text: fields.text({ label: "Button Text" }),
|
|
660
|
+
url: fields.url({ label: "Link URL" }),
|
|
661
|
+
}
|
|
662
|
+
})
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
fields.conditional({
|
|
666
|
+
label: "External Link",
|
|
667
|
+
matchField: "linkType",
|
|
668
|
+
matchValue: "external",
|
|
669
|
+
showField: fields.url({ label: "URL" })
|
|
670
|
+
})
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
| Option | Type | Description |
|
|
674
|
+
|--------|------|-------------|
|
|
675
|
+
| `label` | `string` | Display label |
|
|
676
|
+
| `matchField` | `string` | Field name to check (required) |
|
|
677
|
+
| `matchValue` | `unknown` | Value to match (required) |
|
|
678
|
+
| `showField` | `FieldDefinition` | Field to show when matched (required) |
|
|
679
|
+
|
|
680
|
+
---
|
|
681
|
+
|
|
682
|
+
### Child & Nested Fields
|
|
683
|
+
|
|
684
|
+
#### `fields.child()`
|
|
685
|
+
|
|
686
|
+
Child document content.
|
|
687
|
+
|
|
688
|
+
```typescript
|
|
689
|
+
fields.child({ label: "Page Content" })
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
---
|
|
693
|
+
|
|
694
|
+
### Cloud & Placeholder Fields
|
|
695
|
+
|
|
696
|
+
#### `fields.cloudImage()`
|
|
697
|
+
|
|
698
|
+
Cloud-hosted image (future support).
|
|
699
|
+
|
|
700
|
+
```typescript
|
|
701
|
+
fields.cloudImage({ label: "Profile Picture" })
|
|
702
|
+
fields.cloudImage({
|
|
703
|
+
label: "Avatar",
|
|
704
|
+
provider: "cloudinary"
|
|
705
|
+
})
|
|
706
|
+
```
|
|
707
|
+
|
|
708
|
+
---
|
|
709
|
+
|
|
710
|
+
#### `fields.empty()`
|
|
711
|
+
|
|
712
|
+
Placeholder field (renders nothing).
|
|
713
|
+
|
|
714
|
+
```typescript
|
|
715
|
+
fields.empty({ label: "Reserved" })
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
---
|
|
719
|
+
|
|
720
|
+
#### `fields.emptyContent()`
|
|
721
|
+
|
|
722
|
+
Placeholder for empty content area.
|
|
723
|
+
|
|
724
|
+
```typescript
|
|
725
|
+
fields.emptyContent()
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
---
|
|
729
|
+
|
|
730
|
+
#### `fields.emptyDocument()`
|
|
731
|
+
|
|
732
|
+
Placeholder for empty document section.
|
|
733
|
+
|
|
734
|
+
```typescript
|
|
735
|
+
fields.emptyDocument()
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
---
|
|
739
|
+
|
|
740
|
+
#### `fields.ignored()`
|
|
741
|
+
|
|
742
|
+
Field is skipped in forms (useful for computed fields).
|
|
743
|
+
|
|
744
|
+
```typescript
|
|
745
|
+
fields.ignored({ label: "Internal ID" })
|
|
746
|
+
fields.ignored()
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
---
|
|
750
|
+
|
|
751
|
+
## Validation
|
|
752
|
+
|
|
753
|
+
All fields support validation options:
|
|
754
|
+
|
|
755
|
+
```typescript
|
|
756
|
+
fields.text({
|
|
757
|
+
label: "Title",
|
|
758
|
+
validation: {
|
|
759
|
+
isRequired: true,
|
|
760
|
+
minLength: 3,
|
|
761
|
+
maxLength: 100,
|
|
762
|
+
pattern: "^[A-Za-z]",
|
|
763
|
+
patternDescription: "Must start with a letter"
|
|
764
|
+
}
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
fields.number({
|
|
768
|
+
label: "Price",
|
|
769
|
+
validation: {
|
|
770
|
+
isRequired: true,
|
|
771
|
+
min: 0,
|
|
772
|
+
max: 10000
|
|
773
|
+
}
|
|
774
|
+
})
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
| Validation Option | Type | Applies To |
|
|
778
|
+
|-------------------|------|------------|
|
|
779
|
+
| `isRequired` | `boolean` | All fields |
|
|
780
|
+
| `min` | `number` | `number`, `integer` |
|
|
781
|
+
| `max` | `number` | `number`, `integer` |
|
|
782
|
+
| `minLength` | `number` | `text`, `url` |
|
|
783
|
+
| `maxLength` | `number` | `text`, `url` |
|
|
784
|
+
| `pattern` | `string` | `text`, `slug` |
|
|
785
|
+
| `patternDescription` | `string` | `text`, `slug` |
|
|
786
|
+
|
|
787
|
+
---
|
|
788
|
+
|
|
789
|
+
## Real-World Examples
|
|
790
|
+
|
|
791
|
+
### Blog Post Schema
|
|
792
|
+
|
|
793
|
+
```typescript
|
|
794
|
+
collection({
|
|
795
|
+
name: "blog",
|
|
796
|
+
path: "src/content/blog",
|
|
797
|
+
filePattern: "{slug}.md",
|
|
798
|
+
previewUrl: "/blog/{slug}",
|
|
799
|
+
schema: {
|
|
800
|
+
title: fields.text({
|
|
801
|
+
label: "Title",
|
|
802
|
+
validation: { isRequired: true, maxLength: 100 }
|
|
803
|
+
}),
|
|
804
|
+
slug: fields.slug({
|
|
805
|
+
name: { label: "Slug" },
|
|
806
|
+
pathname: { label: "Path", placeholder: "/blog/" }
|
|
807
|
+
}),
|
|
808
|
+
description: fields.text({
|
|
809
|
+
label: "Description",
|
|
810
|
+
multiline: true,
|
|
811
|
+
validation: { maxLength: 300 }
|
|
812
|
+
}),
|
|
813
|
+
publishedAt: fields.date({ label: "Published Date" }),
|
|
814
|
+
updatedAt: fields.datetime({ label: "Last Updated" }),
|
|
815
|
+
author: fields.relationship({ label: "Author", collection: "authors" }),
|
|
816
|
+
heroImage: fields.image({ label: "Hero Image" }),
|
|
817
|
+
tags: fields.multiselect({
|
|
818
|
+
label: "Tags",
|
|
819
|
+
options: ["javascript", "typescript", "react", "astro", "node"]
|
|
820
|
+
}),
|
|
821
|
+
draft: fields.checkbox({ label: "Draft", defaultValue: true }),
|
|
822
|
+
body: fields.mdx({ label: "Content", validation: { isRequired: true } }),
|
|
823
|
+
}
|
|
824
|
+
})
|
|
825
|
+
```
|
|
826
|
+
|
|
827
|
+
### Documentation Schema
|
|
828
|
+
|
|
829
|
+
```typescript
|
|
830
|
+
collection({
|
|
831
|
+
name: "docs",
|
|
832
|
+
path: "src/content/docs",
|
|
833
|
+
filePattern: "{slug}/index.md",
|
|
834
|
+
previewUrl: "/docs/{slug}",
|
|
835
|
+
schema: {
|
|
836
|
+
title: fields.text({ label: "Title", validation: { isRequired: true } }),
|
|
837
|
+
description: fields.text({ label: "Description" }),
|
|
838
|
+
order: fields.integer({ label: "Sort Order" }),
|
|
839
|
+
category: fields.select({
|
|
840
|
+
label: "Category",
|
|
841
|
+
options: ["getting-started", "guides", "api-reference", "tutorials"]
|
|
842
|
+
}),
|
|
843
|
+
children: fields.child({ label: "Child Pages" }),
|
|
844
|
+
body: fields.markdoc({ label: "Content" }),
|
|
845
|
+
}
|
|
846
|
+
})
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
### Product Catalog Schema
|
|
850
|
+
|
|
851
|
+
```typescript
|
|
852
|
+
collection({
|
|
853
|
+
name: "products",
|
|
854
|
+
path: "src/content/products",
|
|
855
|
+
filePattern: "{slug}.md",
|
|
856
|
+
previewUrl: "/products/{slug}",
|
|
857
|
+
schema: {
|
|
858
|
+
name: fields.text({ label: "Product Name", validation: { isRequired: true } }),
|
|
859
|
+
slug: fields.slug({ name: { label: "URL Slug" } }),
|
|
860
|
+
price: fields.number({ label: "Price" }),
|
|
861
|
+
compareAtPrice: fields.number({ label: "Compare At Price" }),
|
|
862
|
+
sku: fields.text({ label: "SKU" }),
|
|
863
|
+
description: fields.text({ label: "Description", multiline: true }),
|
|
864
|
+
images: fields.array({
|
|
865
|
+
label: "Product Images",
|
|
866
|
+
itemField: fields.image({ label: "Image" }),
|
|
867
|
+
itemLabel: "Image"
|
|
868
|
+
}),
|
|
869
|
+
category: fields.relationship({ label: "Category", collection: "categories" }),
|
|
870
|
+
tags: fields.multiselect({
|
|
871
|
+
label: "Tags",
|
|
872
|
+
options: ["new", "sale", "featured", "bestseller"]
|
|
873
|
+
}),
|
|
874
|
+
inStock: fields.checkbox({ label: "In Stock", defaultValue: true }),
|
|
875
|
+
featured: fields.checkbox({ label: "Featured Product" }),
|
|
876
|
+
specs: fields.object({
|
|
877
|
+
label: "Specifications",
|
|
878
|
+
fields: {
|
|
879
|
+
weight: fields.text({ label: "Weight" }),
|
|
880
|
+
dimensions: fields.text({ label: "Dimensions" }),
|
|
881
|
+
material: fields.text({ label: "Material" }),
|
|
882
|
+
}
|
|
883
|
+
}),
|
|
884
|
+
}
|
|
885
|
+
})
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
### Author Profile Schema
|
|
889
|
+
|
|
890
|
+
```typescript
|
|
891
|
+
collection({
|
|
892
|
+
name: "authors",
|
|
893
|
+
path: "src/content/authors",
|
|
894
|
+
filePattern: "{slug}.md",
|
|
895
|
+
previewUrl: "/authors/{slug}",
|
|
896
|
+
schema: {
|
|
897
|
+
name: fields.text({ label: "Name", validation: { isRequired: true } }),
|
|
898
|
+
slug: fields.slug({ name: { label: "Slug" } }),
|
|
899
|
+
avatar: fields.image({ label: "Avatar" }),
|
|
900
|
+
role: fields.select({
|
|
901
|
+
label: "Role",
|
|
902
|
+
options: ["author", "editor", "contributor", "admin"],
|
|
903
|
+
defaultValue: "author"
|
|
904
|
+
}),
|
|
905
|
+
bio: fields.text({ label: "Bio", multiline: true }),
|
|
906
|
+
social: fields.object({
|
|
907
|
+
label: "Social Links",
|
|
908
|
+
fields: {
|
|
909
|
+
twitter: fields.url({ label: "Twitter" }),
|
|
910
|
+
github: fields.url({ label: "GitHub" }),
|
|
911
|
+
linkedin: fields.url({ label: "LinkedIn" }),
|
|
912
|
+
website: fields.url({ label: "Website" }),
|
|
913
|
+
}
|
|
914
|
+
}),
|
|
915
|
+
email: fields.url({ label: "Email" }),
|
|
916
|
+
featured: fields.checkbox({ label: "Featured Author", defaultValue: false }),
|
|
917
|
+
}
|
|
918
|
+
})
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
---
|
|
922
|
+
|
|
923
|
+
## Migration from Plain Schema
|
|
924
|
+
|
|
925
|
+
If you have an existing plain schema config, here's how to migrate:
|
|
926
|
+
|
|
927
|
+
### Before (Plain Schema)
|
|
928
|
+
|
|
929
|
+
```typescript
|
|
930
|
+
export default defineConfig({
|
|
931
|
+
collections: [
|
|
932
|
+
{
|
|
933
|
+
name: "blog",
|
|
934
|
+
path: "src/content/blog",
|
|
935
|
+
schema: {
|
|
936
|
+
title: { type: "string", required: true },
|
|
937
|
+
description: { type: "string" },
|
|
938
|
+
pubDate: { type: "date", required: true },
|
|
939
|
+
draft: { type: "boolean", default: false },
|
|
940
|
+
tags: { type: "array", items: "string" },
|
|
941
|
+
heroImage: { type: "image" },
|
|
942
|
+
},
|
|
943
|
+
},
|
|
944
|
+
],
|
|
945
|
+
});
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
### After (Fields API)
|
|
949
|
+
|
|
950
|
+
```typescript
|
|
951
|
+
import { defineConfig, collection, fields } from "@imjp/writenex-astro/config";
|
|
952
|
+
|
|
953
|
+
export default defineConfig({
|
|
954
|
+
collections: [
|
|
955
|
+
collection({
|
|
956
|
+
name: "blog",
|
|
957
|
+
path: "src/content/blog",
|
|
958
|
+
schema: {
|
|
959
|
+
title: fields.text({ label: "Title", validation: { isRequired: true } }),
|
|
960
|
+
description: fields.text({ label: "Description" }),
|
|
961
|
+
pubDate: fields.date({ label: "Published Date", validation: { isRequired: true } }),
|
|
962
|
+
draft: fields.checkbox({ label: "Draft", defaultValue: false }),
|
|
963
|
+
tags: fields.array({ label: "Tags", itemField: fields.text({ label: "Tag" }) }),
|
|
964
|
+
heroImage: fields.image({ label: "Hero Image" }),
|
|
965
|
+
},
|
|
966
|
+
}),
|
|
967
|
+
],
|
|
968
|
+
});
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
### Type Mapping
|
|
972
|
+
|
|
973
|
+
| Plain Schema | Fields API |
|
|
974
|
+
|--------------|------------|
|
|
975
|
+
| `type: "string"` | `fields.text()` |
|
|
976
|
+
| `type: "number"` | `fields.number()` |
|
|
977
|
+
| `type: "boolean"` | `fields.checkbox()` |
|
|
978
|
+
| `type: "date"` | `fields.date()` |
|
|
979
|
+
| `type: "array"` | `fields.array({ itemField: ... })` |
|
|
980
|
+
| `type: "object"` | `fields.object({ fields: ... })` |
|
|
981
|
+
| `type: "image"` | `fields.image()` |
|
|
982
|
+
|
|
983
|
+
---
|
|
984
|
+
|
|
144
985
|
## Collection Configuration
|
|
145
986
|
|
|
146
987
|
| Option | Type | Description |
|
|
@@ -149,31 +990,9 @@ The editor is always available at `/_writenex` during development.
|
|
|
149
990
|
| `path` | `string` | Path to collection directory |
|
|
150
991
|
| `filePattern` | `string` | File naming pattern (e.g., `{slug}.md`) |
|
|
151
992
|
| `previewUrl` | `string` | URL pattern for preview links |
|
|
152
|
-
| `schema` | `object` | Frontmatter schema definition
|
|
993
|
+
| `schema` | `object` | Frontmatter schema definition (Fields API) |
|
|
153
994
|
| `images` | `object` | Override image settings for this collection |
|
|
154
995
|
|
|
155
|
-
### Schema Field Types
|
|
156
|
-
|
|
157
|
-
| Type | Form Component | Example Value |
|
|
158
|
-
| --------- | -------------- | ----------------------- |
|
|
159
|
-
| `string` | Text input | `"Hello World"` |
|
|
160
|
-
| `number` | Number input | `42` |
|
|
161
|
-
| `boolean` | Toggle switch | `true` |
|
|
162
|
-
| `date` | Date picker | `"2024-01-15"` |
|
|
163
|
-
| `array` | Tag input | `["astro", "tutorial"]` |
|
|
164
|
-
| `image` | Image uploader | `"./my-post/hero.jpg"` |
|
|
165
|
-
|
|
166
|
-
```typescript
|
|
167
|
-
schema: {
|
|
168
|
-
title: { type: "string", required: true },
|
|
169
|
-
description: { type: "string" },
|
|
170
|
-
pubDate: { type: "date", required: true },
|
|
171
|
-
tags: { type: "array", items: "string" },
|
|
172
|
-
draft: { type: "boolean", default: false },
|
|
173
|
-
heroImage: { type: "image" },
|
|
174
|
-
}
|
|
175
|
-
```
|
|
176
|
-
|
|
177
996
|
## Image Strategies
|
|
178
997
|
|
|
179
998
|
### Colocated (Default)
|
|
@@ -244,9 +1063,9 @@ import { defineConfig } from "@imjp/writenex-astro";
|
|
|
244
1063
|
|
|
245
1064
|
export default defineConfig({
|
|
246
1065
|
versionHistory: {
|
|
247
|
-
enabled: true,
|
|
248
|
-
maxVersions: 20,
|
|
249
|
-
storagePath: ".writenex/versions",
|
|
1066
|
+
enabled: true,
|
|
1067
|
+
maxVersions: 20,
|
|
1068
|
+
storagePath: ".writenex/versions",
|
|
250
1069
|
},
|
|
251
1070
|
});
|
|
252
1071
|
```
|
|
@@ -394,16 +1213,14 @@ Any token in your pattern that is not in the supported list will be resolved fro
|
|
|
394
1213
|
```typescript
|
|
395
1214
|
// writenex.config.ts
|
|
396
1215
|
collections: [
|
|
397
|
-
{
|
|
1216
|
+
collection({
|
|
398
1217
|
name: "docs",
|
|
399
1218
|
path: "src/content/docs",
|
|
400
|
-
filePattern: "{project}/{slug}.md",
|
|
401
|
-
},
|
|
1219
|
+
filePattern: "{project}/{slug}.md",
|
|
1220
|
+
}),
|
|
402
1221
|
];
|
|
403
1222
|
```
|
|
404
1223
|
|
|
405
|
-
When creating content with frontmatter `{ project: "my-app", title: "Getting Started" }`, the file will be created at `src/content/docs/my-app/getting-started.md`.
|
|
406
|
-
|
|
407
1224
|
## Keyboard Shortcuts
|
|
408
1225
|
|
|
409
1226
|
| Shortcut | Action |
|
|
@@ -504,6 +1321,30 @@ writenex({
|
|
|
504
1321
|
2. Check that files have `.md` extension
|
|
505
1322
|
3. Verify frontmatter is valid YAML
|
|
506
1323
|
|
|
1324
|
+
### Config file not loading
|
|
1325
|
+
|
|
1326
|
+
1. Ensure `writenex.config.ts` is in your project root
|
|
1327
|
+
2. Check the file has proper exports: `export default defineConfig({ ... })`
|
|
1328
|
+
3. Restart the dev server after making changes
|
|
1329
|
+
|
|
1330
|
+
### Field types not rendering correctly
|
|
1331
|
+
|
|
1332
|
+
1. Verify the field type is spelled correctly (e.g., `fields.text`, not `fields.string`)
|
|
1333
|
+
2. Check that required config properties are provided (e.g., `options` for `select`)
|
|
1334
|
+
3. For `object` and `array`, ensure `fields` or `itemField` is properly nested
|
|
1335
|
+
|
|
1336
|
+
### Validation not working
|
|
1337
|
+
|
|
1338
|
+
1. Ensure `validation` object is inside the field config, not outside
|
|
1339
|
+
2. Check that validation rules match the field type (e.g., `min`/`max` for numbers)
|
|
1340
|
+
3. Remember `isRequired` only validates on form submission
|
|
1341
|
+
|
|
1342
|
+
### Collection not found for relationship
|
|
1343
|
+
|
|
1344
|
+
1. Verify the `collection` name matches exactly (case-sensitive)
|
|
1345
|
+
2. Ensure the referenced collection is also defined in your config
|
|
1346
|
+
3. Check that the referenced collection has at least one item
|
|
1347
|
+
|
|
507
1348
|
### Images not uploading
|
|
508
1349
|
|
|
509
1350
|
1. Check file permissions on the target directory
|
|
@@ -522,13 +1363,6 @@ writenex({
|
|
|
522
1363
|
- React 18.x or 19.x
|
|
523
1364
|
- Node.js 22.12.0+ (Node 18 and 20 are no longer supported)
|
|
524
1365
|
|
|
525
|
-
### Future Plans
|
|
526
|
-
|
|
527
|
-
- MDX full support (components, imports)
|
|
528
|
-
- CLI wrapper (`npx @imjp/writenex-astro`)
|
|
529
|
-
- Git integration (auto-commit on save)
|
|
530
|
-
- Media library management
|
|
531
|
-
|
|
532
1366
|
## License
|
|
533
1367
|
|
|
534
1368
|
MIT - see [LICENSE](../../LICENSE) for details.
|