@byline/core 3.6.0 → 3.8.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.
- package/dist/@types/db-types.d.ts +2 -0
- package/dist/@types/field-types.d.ts +22 -0
- package/dist/@types/site-config.d.ts +8 -1
- package/dist/schemas/zod/builder.d.ts +42 -0
- package/dist/schemas/zod/builder.js +5 -0
- package/dist/services/document-lifecycle/copy-to-locale.js +2 -1
- package/dist/services/document-lifecycle/create.js +2 -1
- package/dist/services/document-lifecycle/delete-locale.js +2 -1
- package/dist/services/document-lifecycle/duplicate.js +3 -1
- package/dist/services/document-lifecycle/internals.d.ts +8 -0
- package/dist/services/document-lifecycle/internals.js +10 -0
- package/dist/services/document-lifecycle/restore.js +2 -1
- package/dist/services/document-lifecycle/update.js +3 -1
- package/dist/services/document-lifecycle.test.node.js +21 -0
- package/dist/services/document-to-markdown.d.ts +79 -0
- package/dist/services/document-to-markdown.js +267 -0
- package/dist/services/document-to-markdown.test.node.d.ts +8 -0
- package/dist/services/document-to-markdown.test.node.js +161 -0
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/package.json +2 -2
|
@@ -415,6 +415,8 @@ export interface IDocumentCommands {
|
|
|
415
415
|
documentId: string;
|
|
416
416
|
locale: string;
|
|
417
417
|
status?: string;
|
|
418
|
+
/** Acting user id for the version audit trail (`created_by`). See docs/AUDIT.md. */
|
|
419
|
+
createdBy?: string;
|
|
418
420
|
}): Promise<{
|
|
419
421
|
newVersionId: string;
|
|
420
422
|
previousVersionId: string;
|
|
@@ -665,4 +665,26 @@ export interface RichTextEmbedContext {
|
|
|
665
665
|
* each field's `embedRelationsOnSave` flag.
|
|
666
666
|
*/
|
|
667
667
|
export type RichTextEmbedFn = (ctx: RichTextEmbedContext) => Promise<void>;
|
|
668
|
+
/**
|
|
669
|
+
* Context passed to the richtext markdown serializer for one rich-text
|
|
670
|
+
* field value. Read-only and synchronous — the export surface walks the
|
|
671
|
+
* stored editor JSON directly (no editor instantiation, no DB reads).
|
|
672
|
+
*/
|
|
673
|
+
export interface RichTextToMarkdownContext {
|
|
674
|
+
/** The richText field's value (raw editor JSON, possibly stringified). */
|
|
675
|
+
value: unknown;
|
|
676
|
+
/** Field path within the document — e.g. `'body'` or `'content.0.caption'`. */
|
|
677
|
+
fieldPath: string;
|
|
678
|
+
/** Collection path the document belongs to. */
|
|
679
|
+
collectionPath: string;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Server-side markdown serializer contract for the agent-readable export
|
|
683
|
+
* surface (`documentToMarkdown`, `.md` routes, `llms.txt`). Editor
|
|
684
|
+
* adapters export an implementation (e.g. `lexicalToMarkdown` from
|
|
685
|
+
* `@byline/richtext-lexical/server`); installations register one via
|
|
686
|
+
* `ServerConfig.fields.richText.toMarkdown`. One-way and lossy-tolerant
|
|
687
|
+
* by contract — output is never re-imported.
|
|
688
|
+
*/
|
|
689
|
+
export type RichTextToMarkdownFn = (ctx: RichTextToMarkdownContext) => string;
|
|
668
690
|
export {};
|
|
@@ -10,7 +10,7 @@ import type { SlugifierFn } from '../utils/slugify.js';
|
|
|
10
10
|
import type { CollectionAdminConfig } from './admin-types.js';
|
|
11
11
|
import type { CollectionDefinition } from './collection-types.js';
|
|
12
12
|
import type { IDbAdapter } from './db-types.js';
|
|
13
|
-
import type { RichTextEditorComponent, RichTextEmbedFn, RichTextPopulateFn } from './field-types.js';
|
|
13
|
+
import type { RichTextEditorComponent, RichTextEmbedFn, RichTextPopulateFn, RichTextToMarkdownFn } from './field-types.js';
|
|
14
14
|
import type { IStorageProvider } from './storage-types.js';
|
|
15
15
|
export type DbAdapterFn = (args: {
|
|
16
16
|
connectionString: string;
|
|
@@ -307,6 +307,13 @@ export interface ServerConfig<TAdminStore = unknown> extends BaseConfig {
|
|
|
307
307
|
richText?: {
|
|
308
308
|
populate?: RichTextPopulateFn;
|
|
309
309
|
embed?: RichTextEmbedFn;
|
|
310
|
+
/**
|
|
311
|
+
* `toMarkdown` — one-way markdown serializer for the agent-readable
|
|
312
|
+
* export surface (`documentToMarkdown`, `.md` routes, `llms.txt`).
|
|
313
|
+
* Optional: only installations that expose a markdown surface need
|
|
314
|
+
* it. Synchronous and read-only — it walks stored editor JSON.
|
|
315
|
+
*/
|
|
316
|
+
toMarkdown?: RichTextToMarkdownFn;
|
|
310
317
|
};
|
|
311
318
|
};
|
|
312
319
|
}
|
|
@@ -12,6 +12,8 @@ export declare const createBaseSchema: (collection?: CollectionDefinition) => z.
|
|
|
12
12
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
13
13
|
createdAt: z.ZodISODateTime;
|
|
14
14
|
updatedAt: z.ZodISODateTime;
|
|
15
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
16
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
15
17
|
}, z.core.$strip>;
|
|
16
18
|
export declare const createFieldsSchema: (fields: Field[], strict?: boolean) => z.ZodObject<{
|
|
17
19
|
[x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
|
|
@@ -44,6 +46,8 @@ export declare const createCollectionSchemasForPath: (path: string) => {
|
|
|
44
46
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
45
47
|
createdAt: z.ZodISODateTime;
|
|
46
48
|
updatedAt: z.ZodISODateTime;
|
|
49
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
50
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
47
51
|
}, z.core.$strip>;
|
|
48
52
|
fields: z.ZodObject<{
|
|
49
53
|
[x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
|
|
@@ -62,6 +66,8 @@ export declare const createCollectionSchemasForPath: (path: string) => {
|
|
|
62
66
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
63
67
|
createdAt: z.ZodISODateTime;
|
|
64
68
|
updatedAt: z.ZodISODateTime;
|
|
69
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
70
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
65
71
|
}, z.core.$strip>;
|
|
66
72
|
list: z.ZodObject<{
|
|
67
73
|
docs: z.ZodArray<z.ZodObject<{
|
|
@@ -78,6 +84,8 @@ export declare const createCollectionSchemasForPath: (path: string) => {
|
|
|
78
84
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
79
85
|
createdAt: z.ZodISODateTime;
|
|
80
86
|
updatedAt: z.ZodISODateTime;
|
|
87
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
88
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
81
89
|
}, z.core.$strip>>;
|
|
82
90
|
meta: z.ZodObject<{
|
|
83
91
|
page: z.ZodNumber;
|
|
@@ -113,6 +121,8 @@ export declare const createCollectionSchemasForPath: (path: string) => {
|
|
|
113
121
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
114
122
|
createdAt: z.ZodISODateTime;
|
|
115
123
|
updatedAt: z.ZodISODateTime;
|
|
124
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
125
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
116
126
|
}, z.core.$strip>>;
|
|
117
127
|
meta: z.ZodObject<{
|
|
118
128
|
page: z.ZodNumber;
|
|
@@ -140,6 +150,8 @@ export declare const createCollectionSchemasForPath: (path: string) => {
|
|
|
140
150
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
141
151
|
createdAt: z.ZodISODateTime;
|
|
142
152
|
updatedAt: z.ZodISODateTime;
|
|
153
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
154
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
143
155
|
}, z.core.$strip>;
|
|
144
156
|
update: z.ZodObject<{
|
|
145
157
|
[x: string]: z.ZodOptional<z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>>;
|
|
@@ -157,6 +169,8 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
|
|
|
157
169
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
158
170
|
createdAt: z.ZodISODateTime;
|
|
159
171
|
updatedAt: z.ZodISODateTime;
|
|
172
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
173
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
160
174
|
}, z.core.$strip>;
|
|
161
175
|
fields: z.ZodObject<{
|
|
162
176
|
[x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
|
|
@@ -175,6 +189,8 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
|
|
|
175
189
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
176
190
|
createdAt: z.ZodISODateTime;
|
|
177
191
|
updatedAt: z.ZodISODateTime;
|
|
192
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
193
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
178
194
|
}, z.core.$strip>;
|
|
179
195
|
list: z.ZodObject<{
|
|
180
196
|
docs: z.ZodArray<z.ZodObject<{
|
|
@@ -191,6 +207,8 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
|
|
|
191
207
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
192
208
|
createdAt: z.ZodISODateTime;
|
|
193
209
|
updatedAt: z.ZodISODateTime;
|
|
210
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
211
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
194
212
|
}, z.core.$strip>>;
|
|
195
213
|
meta: z.ZodObject<{
|
|
196
214
|
page: z.ZodNumber;
|
|
@@ -226,6 +244,8 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
|
|
|
226
244
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
227
245
|
createdAt: z.ZodISODateTime;
|
|
228
246
|
updatedAt: z.ZodISODateTime;
|
|
247
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
248
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
229
249
|
}, z.core.$strip>>;
|
|
230
250
|
meta: z.ZodObject<{
|
|
231
251
|
page: z.ZodNumber;
|
|
@@ -253,6 +273,8 @@ export declare const createCollectionSchemas: (collection: CollectionDefinition)
|
|
|
253
273
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
254
274
|
createdAt: z.ZodISODateTime;
|
|
255
275
|
updatedAt: z.ZodISODateTime;
|
|
276
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
277
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
256
278
|
}, z.core.$strip>;
|
|
257
279
|
update: z.ZodObject<{
|
|
258
280
|
[x: string]: z.ZodOptional<z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>>;
|
|
@@ -270,6 +292,8 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
|
|
|
270
292
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
271
293
|
createdAt: z.ZodISODateTime;
|
|
272
294
|
updatedAt: z.ZodISODateTime;
|
|
295
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
296
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
273
297
|
}, z.core.$strip>;
|
|
274
298
|
fields: z.ZodObject<{
|
|
275
299
|
[x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
|
|
@@ -288,6 +312,8 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
|
|
|
288
312
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
289
313
|
createdAt: z.ZodISODateTime;
|
|
290
314
|
updatedAt: z.ZodISODateTime;
|
|
315
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
316
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
291
317
|
}, z.core.$strip>;
|
|
292
318
|
list: z.ZodObject<{
|
|
293
319
|
docs: z.ZodArray<z.ZodObject<{
|
|
@@ -304,6 +330,8 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
|
|
|
304
330
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
305
331
|
createdAt: z.ZodISODateTime;
|
|
306
332
|
updatedAt: z.ZodISODateTime;
|
|
333
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
334
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
307
335
|
}, z.core.$strip>>;
|
|
308
336
|
meta: z.ZodObject<{
|
|
309
337
|
page: z.ZodNumber;
|
|
@@ -339,6 +367,8 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
|
|
|
339
367
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
340
368
|
createdAt: z.ZodISODateTime;
|
|
341
369
|
updatedAt: z.ZodISODateTime;
|
|
370
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
371
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
342
372
|
}, z.core.$strip>>;
|
|
343
373
|
meta: z.ZodObject<{
|
|
344
374
|
page: z.ZodNumber;
|
|
@@ -366,6 +396,8 @@ export declare const createTypedCollectionSchemas: (collection: CollectionDefini
|
|
|
366
396
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
367
397
|
createdAt: z.ZodISODateTime;
|
|
368
398
|
updatedAt: z.ZodISODateTime;
|
|
399
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
400
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
369
401
|
}, z.core.$strip>;
|
|
370
402
|
update: z.ZodObject<{
|
|
371
403
|
[x: string]: z.ZodOptional<z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>>;
|
|
@@ -383,6 +415,8 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
|
|
|
383
415
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
384
416
|
createdAt: z.ZodISODateTime;
|
|
385
417
|
updatedAt: z.ZodISODateTime;
|
|
418
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
419
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
386
420
|
}, z.core.$strip>;
|
|
387
421
|
fields: z.ZodObject<{
|
|
388
422
|
[x: string]: z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>;
|
|
@@ -401,6 +435,8 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
|
|
|
401
435
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
402
436
|
createdAt: z.ZodISODateTime;
|
|
403
437
|
updatedAt: z.ZodISODateTime;
|
|
438
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
439
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
404
440
|
}, z.core.$strip>;
|
|
405
441
|
list: z.ZodObject<{
|
|
406
442
|
docs: z.ZodArray<z.ZodObject<{
|
|
@@ -417,6 +453,8 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
|
|
|
417
453
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
418
454
|
createdAt: z.ZodISODateTime;
|
|
419
455
|
updatedAt: z.ZodISODateTime;
|
|
456
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
457
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
420
458
|
}, z.core.$strip>>;
|
|
421
459
|
meta: z.ZodObject<{
|
|
422
460
|
page: z.ZodNumber;
|
|
@@ -452,6 +490,8 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
|
|
|
452
490
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
453
491
|
createdAt: z.ZodISODateTime;
|
|
454
492
|
updatedAt: z.ZodISODateTime;
|
|
493
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
494
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
455
495
|
}, z.core.$strip>>;
|
|
456
496
|
meta: z.ZodObject<{
|
|
457
497
|
page: z.ZodNumber;
|
|
@@ -479,6 +519,8 @@ export declare const createTypedCollectionSchemasForPath: (path: string) => {
|
|
|
479
519
|
hasPublishedVersion: z.ZodOptional<z.ZodBoolean>;
|
|
480
520
|
createdAt: z.ZodISODateTime;
|
|
481
521
|
updatedAt: z.ZodISODateTime;
|
|
522
|
+
createdBy: z.ZodOptional<z.ZodUUID>;
|
|
523
|
+
eventType: z.ZodOptional<z.ZodString>;
|
|
482
524
|
}, z.core.$strip>;
|
|
483
525
|
update: z.ZodObject<{
|
|
484
526
|
[x: string]: z.ZodOptional<z.ZodType<any, unknown, z.core.$ZodTypeInternals<any, unknown>>>;
|
|
@@ -205,6 +205,11 @@ export const createBaseSchema = (collection) => {
|
|
|
205
205
|
hasPublishedVersion: z.boolean().optional(),
|
|
206
206
|
createdAt: z.iso.datetime(),
|
|
207
207
|
updatedAt: z.iso.datetime(),
|
|
208
|
+
// Version audit metadata — acting user + action (see docs/AUDIT.md — Workstream 1).
|
|
209
|
+
// Declared so list/get/history responses carry them through the
|
|
210
|
+
// server-fn parse; Zod would otherwise strip them as undeclared keys.
|
|
211
|
+
createdBy: z.uuid().optional(),
|
|
212
|
+
eventType: z.string().optional(),
|
|
208
213
|
});
|
|
209
214
|
};
|
|
210
215
|
// Create field schemas for a collection.
|
|
@@ -10,7 +10,7 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
|
10
10
|
import { ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
13
|
-
import { applyRichTextEmbed, extractVersionId, invokeHook } from './internals.js';
|
|
13
|
+
import { actorId, applyRichTextEmbed, extractVersionId, invokeHook } from './internals.js';
|
|
14
14
|
import { mergeLocaleData } from './merge-locale-data.js';
|
|
15
15
|
/**
|
|
16
16
|
* Copy a document's content from one locale into another, in place on
|
|
@@ -133,6 +133,7 @@ export async function copyToLocale(ctx, params) {
|
|
|
133
133
|
status: getDefaultStatus(definition),
|
|
134
134
|
locale: params.targetLocale,
|
|
135
135
|
previousVersionId,
|
|
136
|
+
createdBy: actorId(ctx),
|
|
136
137
|
});
|
|
137
138
|
const documentVersionId = extractVersionId(writeResult.document);
|
|
138
139
|
await invokeHook(hooks?.afterUpdate, {
|
|
@@ -13,7 +13,7 @@ import { normaliseDateFields } from '../../utils/normalise-dates.js';
|
|
|
13
13
|
import { slugify } from '../../utils/slugify.js';
|
|
14
14
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
15
15
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
16
|
-
import { applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
|
|
16
|
+
import { actorId, applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, maybeAppendOrderKey, rethrowPathConflict, } from './internals.js';
|
|
17
17
|
/**
|
|
18
18
|
* Create a new document.
|
|
19
19
|
*
|
|
@@ -75,6 +75,7 @@ export async function createDocument(ctx, params) {
|
|
|
75
75
|
status: params.status ?? data.status ?? getDefaultStatus(definition),
|
|
76
76
|
locale: params.locale ?? defaultLocale,
|
|
77
77
|
orderKey,
|
|
78
|
+
createdBy: actorId(ctx),
|
|
78
79
|
})
|
|
79
80
|
.catch((err) => rethrowPathConflict(err, resolvedPath, defaultLocale));
|
|
80
81
|
const documentId = extractDocumentId(result.document);
|
|
@@ -10,7 +10,7 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
|
10
10
|
import { ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
13
|
-
import { invokeHook } from './internals.js';
|
|
13
|
+
import { actorId, invokeHook } from './internals.js';
|
|
14
14
|
/**
|
|
15
15
|
* Remove one content locale's data from a document, in place on the same
|
|
16
16
|
* document, by writing a new immutable version that omits that locale's
|
|
@@ -90,6 +90,7 @@ export async function deleteLocale(ctx, params) {
|
|
|
90
90
|
documentId: params.documentId,
|
|
91
91
|
locale: params.locale,
|
|
92
92
|
status: getDefaultStatus(definition),
|
|
93
|
+
createdBy: actorId(ctx),
|
|
93
94
|
});
|
|
94
95
|
if (result == null) {
|
|
95
96
|
throw ERR_NOT_FOUND({
|
|
@@ -12,7 +12,7 @@ import { withLogContext } from '../../lib/logger.js';
|
|
|
12
12
|
import { slugify } from '../../utils/slugify.js';
|
|
13
13
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
14
14
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
15
|
-
import { applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, isPathConflictError, maybeAppendOrderKey, rethrowPathConflict, stripMetaIdsInPlace, } from './internals.js';
|
|
15
|
+
import { actorId, applyRichTextEmbed, derivePath, extractDocumentId, extractVersionId, invokeHook, isPathConflictError, maybeAppendOrderKey, rethrowPathConflict, stripMetaIdsInPlace, } from './internals.js';
|
|
16
16
|
/**
|
|
17
17
|
* Apply the `" (copy)"` suffix to the configured `useAsTitle` field on a
|
|
18
18
|
* duplicate's data tree. Handles both shapes:
|
|
@@ -170,6 +170,7 @@ export async function duplicateDocument(ctx, params) {
|
|
|
170
170
|
status: defaultStatus,
|
|
171
171
|
locale: 'all',
|
|
172
172
|
orderKey,
|
|
173
|
+
createdBy: actorId(ctx),
|
|
173
174
|
})
|
|
174
175
|
.catch((err) => rethrowPathConflict(err, finalPath, defaultLocale));
|
|
175
176
|
}
|
|
@@ -194,6 +195,7 @@ export async function duplicateDocument(ctx, params) {
|
|
|
194
195
|
status: defaultStatus,
|
|
195
196
|
locale: 'all',
|
|
196
197
|
orderKey,
|
|
198
|
+
createdBy: actorId(ctx),
|
|
197
199
|
})
|
|
198
200
|
.catch((retryErr) => rethrowPathConflict(retryErr, finalPath, defaultLocale));
|
|
199
201
|
}
|
|
@@ -15,6 +15,14 @@ import { type CollectionDefinition, type CollectionHookSlot } from '../../@types
|
|
|
15
15
|
import type { BylineLogger } from '../../lib/logger.js';
|
|
16
16
|
import type { SlugifierFn } from '../../utils/slugify.js';
|
|
17
17
|
import type { DocumentLifecycleContext } from './context.js';
|
|
18
|
+
/**
|
|
19
|
+
* The acting user's id for the version audit trail (`created_by` on
|
|
20
|
+
* `byline_document_versions`). `undefined` for internal-tooling callers
|
|
21
|
+
* without a `requestContext` (seeds, migrations — the documented escape
|
|
22
|
+
* hatch), which persists as NULL. Both actor realms (`AdminAuth`,
|
|
23
|
+
* `UserAuth`) carry `id`. See docs/AUDIT.md — Workstream 1.
|
|
24
|
+
*/
|
|
25
|
+
export declare function actorId(ctx: DocumentLifecycleContext): string | undefined;
|
|
18
26
|
/**
|
|
19
27
|
* Safely invoke an optional hook slot, awaiting the result if it returns a
|
|
20
28
|
* Promise. When the slot is an array of functions they are executed
|
|
@@ -17,6 +17,16 @@ import { ERR_PATH_CONFLICT, ErrorCodes } from '../../lib/errors.js';
|
|
|
17
17
|
import { generateKeyBetween } from '../../lib/fractional-index.js';
|
|
18
18
|
import { createReadContext } from '../populate.js';
|
|
19
19
|
import { embedRichTextFields } from '../richtext-embed.js';
|
|
20
|
+
/**
|
|
21
|
+
* The acting user's id for the version audit trail (`created_by` on
|
|
22
|
+
* `byline_document_versions`). `undefined` for internal-tooling callers
|
|
23
|
+
* without a `requestContext` (seeds, migrations — the documented escape
|
|
24
|
+
* hatch), which persists as NULL. Both actor realms (`AdminAuth`,
|
|
25
|
+
* `UserAuth`) carry `id`. See docs/AUDIT.md — Workstream 1.
|
|
26
|
+
*/
|
|
27
|
+
export function actorId(ctx) {
|
|
28
|
+
return ctx.requestContext?.actor?.id;
|
|
29
|
+
}
|
|
20
30
|
/**
|
|
21
31
|
* Safely invoke an optional hook slot, awaiting the result if it returns a
|
|
22
32
|
* Promise. When the slot is an array of functions they are executed
|
|
@@ -10,7 +10,7 @@ import { assertActorCanPerform } from '../../auth/assert-actor-can-perform.js';
|
|
|
10
10
|
import { ERR_INVALID_TRANSITION, ERR_NOT_FOUND, ERR_VALIDATION } from '../../lib/errors.js';
|
|
11
11
|
import { withLogContext } from '../../lib/logger.js';
|
|
12
12
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
13
|
-
import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook } from './internals.js';
|
|
13
|
+
import { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, } from './internals.js';
|
|
14
14
|
/**
|
|
15
15
|
* Restore a historical document version as the new current version.
|
|
16
16
|
*
|
|
@@ -139,6 +139,7 @@ export async function restoreDocumentVersion(ctx, params) {
|
|
|
139
139
|
status: getDefaultStatus(definition),
|
|
140
140
|
locale: 'all',
|
|
141
141
|
previousVersionId: currentMeta.document_version_id,
|
|
142
|
+
createdBy: actorId(ctx),
|
|
142
143
|
});
|
|
143
144
|
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
144
145
|
const documentVersionId = extractVersionId(result.document);
|
|
@@ -13,7 +13,7 @@ import { applyPatches } from '../../patches/index.js';
|
|
|
13
13
|
import { normaliseDateFields } from '../../utils/normalise-dates.js';
|
|
14
14
|
import { getDefaultStatus } from '../../workflow/workflow.js';
|
|
15
15
|
import { assignCounterValues } from '../assign-counter-values.js';
|
|
16
|
-
import { applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, } from './internals.js';
|
|
16
|
+
import { actorId, applyRichTextEmbed, extractDocumentId, extractVersionId, invokeHook, resolvePathForUpdate, rethrowPathConflict, } from './internals.js';
|
|
17
17
|
/**
|
|
18
18
|
* Update a document via full replacement (PUT semantics).
|
|
19
19
|
*
|
|
@@ -85,6 +85,7 @@ export async function updateDocument(ctx, params) {
|
|
|
85
85
|
status: defaultStatus,
|
|
86
86
|
locale: requestLocale,
|
|
87
87
|
previousVersionId: originalData.document_version_id,
|
|
88
|
+
createdBy: actorId(ctx),
|
|
88
89
|
})
|
|
89
90
|
.catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
|
|
90
91
|
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
@@ -198,6 +199,7 @@ export async function updateDocumentWithPatches(ctx, params) {
|
|
|
198
199
|
status: defaultStatus,
|
|
199
200
|
locale: requestLocale,
|
|
200
201
|
previousVersionId: originalData.document_version_id,
|
|
202
|
+
createdBy: actorId(ctx),
|
|
201
203
|
})
|
|
202
204
|
.catch((err) => rethrowPathConflict(err, pathForCommand ?? '', defaultLocale));
|
|
203
205
|
const documentId = extractDocumentId(result.document) || params.documentId;
|
|
@@ -139,6 +139,17 @@ describe('Document lifecycle service', () => {
|
|
|
139
139
|
expect(result.documentId).toBe('doc-1');
|
|
140
140
|
expect(result.documentVersionId).toBe('ver-1');
|
|
141
141
|
});
|
|
142
|
+
it('passes the acting user id as createdBy for the audit trail', async () => {
|
|
143
|
+
const { db, createDocumentVersion } = createMockDb();
|
|
144
|
+
const ctx = buildCtx(db);
|
|
145
|
+
await createDocument(ctx, {
|
|
146
|
+
data: { title: 'Hello' },
|
|
147
|
+
locale: 'en',
|
|
148
|
+
});
|
|
149
|
+
// Audit contract (docs/AUDIT.md — W1): every version row
|
|
150
|
+
// records the actor that created it.
|
|
151
|
+
expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBe('test-super-admin');
|
|
152
|
+
});
|
|
142
153
|
it('invokes beforeCreate and afterCreate hooks in order', async () => {
|
|
143
154
|
const callOrder = [];
|
|
144
155
|
const hooks = {
|
|
@@ -307,6 +318,16 @@ describe('Document lifecycle service', () => {
|
|
|
307
318
|
// updateDocument (PUT)
|
|
308
319
|
// -----------------------------------------------------------------------
|
|
309
320
|
describe('updateDocument', () => {
|
|
321
|
+
it('passes the acting user id as createdBy for the audit trail', async () => {
|
|
322
|
+
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
323
|
+
getDocumentById.mockResolvedValue({ status: 'draft', fields: { title: 'Old' } });
|
|
324
|
+
const ctx = buildCtx(db);
|
|
325
|
+
await updateDocument(ctx, {
|
|
326
|
+
documentId: 'doc-1',
|
|
327
|
+
data: { title: 'New' },
|
|
328
|
+
});
|
|
329
|
+
expect(createDocumentVersion.mock.calls[0]?.[0].createdBy).toBe('test-super-admin');
|
|
330
|
+
});
|
|
310
331
|
it('fetches the original before calling hooks', async () => {
|
|
311
332
|
const { db, getDocumentById, createDocumentVersion } = createMockDb();
|
|
312
333
|
getDocumentById.mockResolvedValue({ status: 'draft', fields: { title: 'Old' } });
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* `documentToMarkdown` — the document-grain markdown assembler for the
|
|
10
|
+
* agent-readable export surface (`.md` routes, `llms.txt`). See
|
|
11
|
+
* docs/TODO.md → "Markdown export".
|
|
12
|
+
*
|
|
13
|
+
* A page is a composite (text, richtext, blocks, arrays, relations); this
|
|
14
|
+
* walks the collection's schema and the locale-resolved field data in
|
|
15
|
+
* lockstep and emits ONE markdown document: YAML frontmatter, an H1 from
|
|
16
|
+
* `useAsTitle`, then fields as sections. One-way and lossy-tolerant by
|
|
17
|
+
* contract — the output is read-only and never re-imported.
|
|
18
|
+
*
|
|
19
|
+
* Editor-agnostic: rich-text leaves are delegated to the registered
|
|
20
|
+
* `RichTextToMarkdownFn` (the seam beside `embed` / `populate` in
|
|
21
|
+
* `ServerConfig.fields.richText`), passed in explicitly via options so
|
|
22
|
+
* this module stays pure and unit-testable without `initBylineCore()`.
|
|
23
|
+
* Routing knowledge stays out of core the same way: relation and file
|
|
24
|
+
* URLs resolve through caller-supplied callbacks.
|
|
25
|
+
*
|
|
26
|
+
* Rendering rules (the format contract — see the unit tests):
|
|
27
|
+
* - `useAsTitle` field → frontmatter `title` + body `# H1` (not repeated
|
|
28
|
+
* as a section).
|
|
29
|
+
* - A field named `summary` → frontmatter `description` + an unlabelled
|
|
30
|
+
* lead paragraph (the standfirst).
|
|
31
|
+
* - `richText` fields and `blocks` fields render their content directly,
|
|
32
|
+
* with no `## Label` heading — they ARE the document body.
|
|
33
|
+
* - Scalar fields (text, textArea, datetime, select, checkbox, numbers)
|
|
34
|
+
* → `**Label:** value` lines.
|
|
35
|
+
* - `relation` → `**Label:** [title](url)` when populated + resolvable.
|
|
36
|
+
* - `image` / `file` → ``.
|
|
37
|
+
* - `group` → `## Label` + nested walk; `array` → `## Label` + items.
|
|
38
|
+
* - Empty values are skipped entirely — no empty headings.
|
|
39
|
+
*/
|
|
40
|
+
import { type CollectionDefinition, type RichTextToMarkdownFn } from '../@types/index.js';
|
|
41
|
+
export interface MarkdownSourceDocument {
|
|
42
|
+
/** The document's URL slug (`byline_document_paths` projection). */
|
|
43
|
+
path?: string;
|
|
44
|
+
/** Locale-resolved, camelCase field data (the `ClientDocument.fields` shape). */
|
|
45
|
+
fields: Record<string, any>;
|
|
46
|
+
updatedAt?: Date | string;
|
|
47
|
+
}
|
|
48
|
+
export interface DocumentToMarkdownOptions {
|
|
49
|
+
/**
|
|
50
|
+
* The content locale this render represents (one `.md` variant per
|
|
51
|
+
* content locale — same cache key dimension as the HTML page).
|
|
52
|
+
*/
|
|
53
|
+
locale?: string;
|
|
54
|
+
/** Absolute canonical URL of the HTML page; emitted into frontmatter. */
|
|
55
|
+
canonicalUrl?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Rich-text serializer (the `ServerConfig.fields.richText.toMarkdown`
|
|
58
|
+
* seam). Without it, rich-text leaves are skipped.
|
|
59
|
+
*/
|
|
60
|
+
richTextToMarkdown?: RichTextToMarkdownFn;
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a relation target to a public URL. Receives the target's
|
|
63
|
+
* collection path and document slug; return `undefined` to render the
|
|
64
|
+
* relation as plain text (no link).
|
|
65
|
+
*/
|
|
66
|
+
resolveUrl?: (collectionPath: string, documentPath: string) => string | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Resolve an upload field value (`StoredFileValue`) to a public URL.
|
|
69
|
+
* Falls back to the value's own `storageUrl`; the image is skipped when
|
|
70
|
+
* neither yields a URL.
|
|
71
|
+
*/
|
|
72
|
+
resolveFileUrl?: (value: Record<string, any>) => string | undefined;
|
|
73
|
+
/** Extra frontmatter entries, merged after the standard keys. */
|
|
74
|
+
frontmatter?: Record<string, unknown>;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Render one document to a markdown string (frontmatter + body).
|
|
78
|
+
*/
|
|
79
|
+
export declare function documentToMarkdown(doc: MarkdownSourceDocument, definition: CollectionDefinition, options?: DocumentToMarkdownOptions): string;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* `documentToMarkdown` — the document-grain markdown assembler for the
|
|
10
|
+
* agent-readable export surface (`.md` routes, `llms.txt`). See
|
|
11
|
+
* docs/TODO.md → "Markdown export".
|
|
12
|
+
*
|
|
13
|
+
* A page is a composite (text, richtext, blocks, arrays, relations); this
|
|
14
|
+
* walks the collection's schema and the locale-resolved field data in
|
|
15
|
+
* lockstep and emits ONE markdown document: YAML frontmatter, an H1 from
|
|
16
|
+
* `useAsTitle`, then fields as sections. One-way and lossy-tolerant by
|
|
17
|
+
* contract — the output is read-only and never re-imported.
|
|
18
|
+
*
|
|
19
|
+
* Editor-agnostic: rich-text leaves are delegated to the registered
|
|
20
|
+
* `RichTextToMarkdownFn` (the seam beside `embed` / `populate` in
|
|
21
|
+
* `ServerConfig.fields.richText`), passed in explicitly via options so
|
|
22
|
+
* this module stays pure and unit-testable without `initBylineCore()`.
|
|
23
|
+
* Routing knowledge stays out of core the same way: relation and file
|
|
24
|
+
* URLs resolve through caller-supplied callbacks.
|
|
25
|
+
*
|
|
26
|
+
* Rendering rules (the format contract — see the unit tests):
|
|
27
|
+
* - `useAsTitle` field → frontmatter `title` + body `# H1` (not repeated
|
|
28
|
+
* as a section).
|
|
29
|
+
* - A field named `summary` → frontmatter `description` + an unlabelled
|
|
30
|
+
* lead paragraph (the standfirst).
|
|
31
|
+
* - `richText` fields and `blocks` fields render their content directly,
|
|
32
|
+
* with no `## Label` heading — they ARE the document body.
|
|
33
|
+
* - Scalar fields (text, textArea, datetime, select, checkbox, numbers)
|
|
34
|
+
* → `**Label:** value` lines.
|
|
35
|
+
* - `relation` → `**Label:** [title](url)` when populated + resolvable.
|
|
36
|
+
* - `image` / `file` → ``.
|
|
37
|
+
* - `group` → `## Label` + nested walk; `array` → `## Label` + items.
|
|
38
|
+
* - Empty values are skipped entirely — no empty headings.
|
|
39
|
+
*/
|
|
40
|
+
import { isArrayField, isBlocksField, isGroupField, } from '../@types/index.js';
|
|
41
|
+
/**
|
|
42
|
+
* Render one document to a markdown string (frontmatter + body).
|
|
43
|
+
*/
|
|
44
|
+
export function documentToMarkdown(doc, definition, options = {}) {
|
|
45
|
+
const ctx = { definition, options };
|
|
46
|
+
const fields = doc.fields ?? {};
|
|
47
|
+
const title = stringValue(resolveLocalized(fields[definition.useAsTitle ?? ''], options.locale));
|
|
48
|
+
const summary = stringValue(resolveLocalized(fields.summary, options.locale));
|
|
49
|
+
const published = dateValue(resolveLocalized(fields.publishedOn, options.locale));
|
|
50
|
+
// --- frontmatter -------------------------------------------------------
|
|
51
|
+
const fm = {};
|
|
52
|
+
if (title)
|
|
53
|
+
fm.title = title;
|
|
54
|
+
if (summary)
|
|
55
|
+
fm.description = summary;
|
|
56
|
+
if (options.canonicalUrl)
|
|
57
|
+
fm.canonical = options.canonicalUrl;
|
|
58
|
+
if (options.locale)
|
|
59
|
+
fm.locale = options.locale;
|
|
60
|
+
fm.collection = definition.path;
|
|
61
|
+
if (published)
|
|
62
|
+
fm.published = published;
|
|
63
|
+
const updated = dateValue(doc.updatedAt);
|
|
64
|
+
if (updated)
|
|
65
|
+
fm.updated = updated;
|
|
66
|
+
Object.assign(fm, options.frontmatter ?? {});
|
|
67
|
+
// --- body ---------------------------------------------------------------
|
|
68
|
+
const blocks = [];
|
|
69
|
+
if (title)
|
|
70
|
+
blocks.push(`# ${title}`);
|
|
71
|
+
if (summary)
|
|
72
|
+
blocks.push(summary);
|
|
73
|
+
// Fields already represented above never repeat as sections.
|
|
74
|
+
const handled = new Set([definition.useAsTitle, 'summary', 'publishedOn']);
|
|
75
|
+
blocks.push(...serializeFieldSet(definition.fields, fields, ctx, 2, handled));
|
|
76
|
+
return `${renderFrontmatter(fm)}\n\n${blocks.join('\n\n')}\n`;
|
|
77
|
+
}
|
|
78
|
+
function serializeFieldSet(fields, data, ctx, level, skip) {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const field of fields) {
|
|
81
|
+
if (skip?.has(field.name))
|
|
82
|
+
continue;
|
|
83
|
+
const value = resolveLocalized(data?.[field.name], ctx.options.locale);
|
|
84
|
+
if (isEmpty(value))
|
|
85
|
+
continue;
|
|
86
|
+
const rendered = serializeField(field, value, ctx, level);
|
|
87
|
+
if (rendered != null && rendered.length > 0)
|
|
88
|
+
out.push(rendered);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
function serializeField(field, value, ctx, level) {
|
|
93
|
+
if (isGroupField(field)) {
|
|
94
|
+
const inner = serializeFieldSet(field.fields, asRecord(value), ctx, level + 1);
|
|
95
|
+
if (inner.length === 0)
|
|
96
|
+
return null;
|
|
97
|
+
return [heading(level, field.label ?? field.name), ...inner].join('\n\n');
|
|
98
|
+
}
|
|
99
|
+
if (isArrayField(field)) {
|
|
100
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
101
|
+
return null;
|
|
102
|
+
const items = value
|
|
103
|
+
.map((item) => serializeFieldSet(field.fields, asRecord(item), ctx, level + 1).join('\n\n'))
|
|
104
|
+
.filter((s) => s.length > 0);
|
|
105
|
+
if (items.length === 0)
|
|
106
|
+
return null;
|
|
107
|
+
return [heading(level, field.label ?? field.name), ...items].join('\n\n');
|
|
108
|
+
}
|
|
109
|
+
if (isBlocksField(field)) {
|
|
110
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
111
|
+
return null;
|
|
112
|
+
const rendered = [];
|
|
113
|
+
for (const item of value) {
|
|
114
|
+
const record = asRecord(item);
|
|
115
|
+
const block = field.blocks.find((b) => b.blockType === record._type);
|
|
116
|
+
if (block == null)
|
|
117
|
+
continue;
|
|
118
|
+
// Blocks are the document body: their fields render directly, with
|
|
119
|
+
// no per-block heading and no `**Label:**` prefix for richtext.
|
|
120
|
+
const inner = serializeFieldSet(block.fields, record, ctx, level);
|
|
121
|
+
if (inner.length > 0)
|
|
122
|
+
rendered.push(inner.join('\n\n'));
|
|
123
|
+
}
|
|
124
|
+
return rendered.length > 0 ? rendered.join('\n\n') : null;
|
|
125
|
+
}
|
|
126
|
+
switch (field.type) {
|
|
127
|
+
case 'richText': {
|
|
128
|
+
const toMarkdown = ctx.options.richTextToMarkdown;
|
|
129
|
+
if (toMarkdown == null)
|
|
130
|
+
return null;
|
|
131
|
+
const markdown = toMarkdown({
|
|
132
|
+
value,
|
|
133
|
+
fieldPath: field.name,
|
|
134
|
+
collectionPath: ctx.definition.path,
|
|
135
|
+
});
|
|
136
|
+
return markdown.trim().length > 0 ? markdown.trim() : null;
|
|
137
|
+
}
|
|
138
|
+
case 'relation':
|
|
139
|
+
return serializeRelation(field, value, ctx);
|
|
140
|
+
case 'image':
|
|
141
|
+
case 'file':
|
|
142
|
+
return serializeUpload(field, value, ctx);
|
|
143
|
+
case 'date':
|
|
144
|
+
case 'time':
|
|
145
|
+
case 'datetime': {
|
|
146
|
+
const iso = dateValue(value);
|
|
147
|
+
return iso ? labelled(field, iso) : null;
|
|
148
|
+
}
|
|
149
|
+
case 'checkbox':
|
|
150
|
+
case 'boolean':
|
|
151
|
+
case 'json':
|
|
152
|
+
case 'object':
|
|
153
|
+
// No markdown projection: booleans are almost always presentation
|
|
154
|
+
// toggles (constrainedWidth, featured) and json/object is
|
|
155
|
+
// machine-shaped — the export renders content, not configuration.
|
|
156
|
+
return null;
|
|
157
|
+
default: {
|
|
158
|
+
const text = stringValue(value);
|
|
159
|
+
return text ? labelled(field, text) : null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function serializeRelation(field, value, ctx) {
|
|
164
|
+
const record = asRecord(value);
|
|
165
|
+
const target = asRecord(record.document);
|
|
166
|
+
const targetFields = asRecord(target.fields);
|
|
167
|
+
const displayField = field.displayField;
|
|
168
|
+
const title = stringValue(displayField ? targetFields[displayField] : undefined) ??
|
|
169
|
+
stringValue(targetFields.title) ??
|
|
170
|
+
stringValue(target.path);
|
|
171
|
+
if (record._resolved !== true || title == null)
|
|
172
|
+
return null;
|
|
173
|
+
const targetCollection = field.targetCollection;
|
|
174
|
+
const url = targetCollection && typeof target.path === 'string'
|
|
175
|
+
? ctx.options.resolveUrl?.(targetCollection, target.path)
|
|
176
|
+
: undefined;
|
|
177
|
+
return labelled(field, url ? `[${title}](${url})` : title);
|
|
178
|
+
}
|
|
179
|
+
function serializeUpload(field, value, ctx) {
|
|
180
|
+
const record = asRecord(value);
|
|
181
|
+
const url = ctx.options.resolveFileUrl?.(record) ?? stringValue(record.storageUrl);
|
|
182
|
+
if (url == null)
|
|
183
|
+
return null;
|
|
184
|
+
const alt = stringValue(record.alt) ??
|
|
185
|
+
stringValue(record.originalFilename) ??
|
|
186
|
+
stringValue(field.label) ??
|
|
187
|
+
field.name;
|
|
188
|
+
return `![${alt.replace(/[[\]]/g, '')}](${url})`;
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Value helpers
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
/**
|
|
194
|
+
* Locale-scoped reads deliver flat values; `locale: 'all'` reads deliver
|
|
195
|
+
* `{ en: …, fr: … }` envelopes. Pick the requested locale (or the first
|
|
196
|
+
* available) when an envelope sneaks through, so the export never prints
|
|
197
|
+
* `[object Object]`.
|
|
198
|
+
*/
|
|
199
|
+
function resolveLocalized(value, locale) {
|
|
200
|
+
if (value != null &&
|
|
201
|
+
typeof value === 'object' &&
|
|
202
|
+
!Array.isArray(value) &&
|
|
203
|
+
!(value instanceof Date)) {
|
|
204
|
+
const record = value;
|
|
205
|
+
const keys = Object.keys(record);
|
|
206
|
+
const localeLike = keys.length > 0 && keys.every((k) => /^[a-z]{2}(-[A-Za-z]{2,4})?$/.test(k));
|
|
207
|
+
if (localeLike) {
|
|
208
|
+
if (locale && locale in record)
|
|
209
|
+
return record[locale];
|
|
210
|
+
return record[keys[0]];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
function asRecord(value) {
|
|
216
|
+
return value != null && typeof value === 'object' && !Array.isArray(value)
|
|
217
|
+
? value
|
|
218
|
+
: {};
|
|
219
|
+
}
|
|
220
|
+
function isEmpty(value) {
|
|
221
|
+
if (value == null)
|
|
222
|
+
return true;
|
|
223
|
+
if (typeof value === 'string')
|
|
224
|
+
return value.trim().length === 0;
|
|
225
|
+
if (Array.isArray(value))
|
|
226
|
+
return value.length === 0;
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
function stringValue(value) {
|
|
230
|
+
if (typeof value === 'string')
|
|
231
|
+
return value.trim().length > 0 ? value : null;
|
|
232
|
+
if (typeof value === 'number')
|
|
233
|
+
return String(value);
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
function dateValue(value) {
|
|
237
|
+
if (value instanceof Date)
|
|
238
|
+
return value.toISOString();
|
|
239
|
+
if (typeof value === 'string') {
|
|
240
|
+
const date = new Date(value);
|
|
241
|
+
if (!Number.isNaN(date.getTime()))
|
|
242
|
+
return date.toISOString();
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
function heading(level, text) {
|
|
247
|
+
return `${'#'.repeat(Math.min(level, 6))} ${text}`;
|
|
248
|
+
}
|
|
249
|
+
function labelled(field, value) {
|
|
250
|
+
return `**${field.label ?? field.name}:** ${value}`;
|
|
251
|
+
}
|
|
252
|
+
/** Minimal YAML emitter — flat keys, quoted strings, no nesting needed. */
|
|
253
|
+
function renderFrontmatter(entries) {
|
|
254
|
+
const lines = ['---'];
|
|
255
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
256
|
+
if (value == null)
|
|
257
|
+
continue;
|
|
258
|
+
if (typeof value === 'string') {
|
|
259
|
+
lines.push(`${key}: "${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
lines.push(`${key}: ${String(value)}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
lines.push('---');
|
|
266
|
+
return lines.join('\n');
|
|
267
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Contract tests for the document-grain markdown assembler. The expected
|
|
10
|
+
* strings ARE the format contract — agents build on this shape, so a
|
|
11
|
+
* change here is a consumer-visible format change and should be
|
|
12
|
+
* deliberate (docs/TODO.md → "The output is a contract surface").
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
import { documentToMarkdown } from './document-to-markdown.js';
|
|
16
|
+
const definition = {
|
|
17
|
+
path: 'docs',
|
|
18
|
+
labels: { singular: 'Doc', plural: 'Docs' },
|
|
19
|
+
useAsTitle: 'title',
|
|
20
|
+
fields: [
|
|
21
|
+
{ name: 'title', label: 'Title', type: 'text', localized: true },
|
|
22
|
+
{ name: 'summary', label: 'Summary', type: 'textArea', localized: true },
|
|
23
|
+
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
|
|
24
|
+
{ name: 'publishedOn', label: 'Published On', type: 'datetime' },
|
|
25
|
+
{ name: 'featured', label: 'Featured', type: 'checkbox', optional: true },
|
|
26
|
+
{
|
|
27
|
+
name: 'featureImage',
|
|
28
|
+
label: 'Feature Image',
|
|
29
|
+
type: 'relation',
|
|
30
|
+
targetCollection: 'media',
|
|
31
|
+
displayField: 'title',
|
|
32
|
+
optional: true,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'meta',
|
|
36
|
+
label: 'Meta',
|
|
37
|
+
type: 'group',
|
|
38
|
+
fields: [{ name: 'keywords', label: 'Keywords', type: 'text', optional: true }],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'content',
|
|
42
|
+
label: 'Content',
|
|
43
|
+
type: 'blocks',
|
|
44
|
+
optional: true,
|
|
45
|
+
blocks: [
|
|
46
|
+
{
|
|
47
|
+
blockType: 'richTextBlock',
|
|
48
|
+
labels: { singular: 'Rich Text', plural: 'Rich Texts' },
|
|
49
|
+
fields: [{ name: 'richText', label: 'Rich Text', type: 'richText', localized: true }],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
const baseDoc = {
|
|
56
|
+
path: 'getting-started',
|
|
57
|
+
updatedAt: new Date('2026-06-01T12:00:00Z'),
|
|
58
|
+
fields: {
|
|
59
|
+
title: 'Getting Started',
|
|
60
|
+
summary: 'How to begin.',
|
|
61
|
+
publishedOn: new Date('2026-05-01T00:00:00Z'),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
describe('documentToMarkdown', () => {
|
|
65
|
+
it('renders frontmatter, H1, and the summary standfirst', () => {
|
|
66
|
+
const markdown = documentToMarkdown(baseDoc, definition, {
|
|
67
|
+
locale: 'en',
|
|
68
|
+
canonicalUrl: 'https://example.com/docs/getting-started',
|
|
69
|
+
});
|
|
70
|
+
expect(markdown).toBe([
|
|
71
|
+
'---',
|
|
72
|
+
'title: "Getting Started"',
|
|
73
|
+
'description: "How to begin."',
|
|
74
|
+
'canonical: "https://example.com/docs/getting-started"',
|
|
75
|
+
'locale: "en"',
|
|
76
|
+
'collection: "docs"',
|
|
77
|
+
'published: "2026-05-01T00:00:00.000Z"',
|
|
78
|
+
'updated: "2026-06-01T12:00:00.000Z"',
|
|
79
|
+
'---',
|
|
80
|
+
'',
|
|
81
|
+
'# Getting Started',
|
|
82
|
+
'',
|
|
83
|
+
'How to begin.',
|
|
84
|
+
'',
|
|
85
|
+
].join('\n'));
|
|
86
|
+
});
|
|
87
|
+
it('renders scalar fields as labelled lines and skips empty values', () => {
|
|
88
|
+
const markdown = documentToMarkdown({
|
|
89
|
+
...baseDoc,
|
|
90
|
+
fields: { ...baseDoc.fields, subtitle: 'A subtitle', featured: true },
|
|
91
|
+
}, definition, {});
|
|
92
|
+
expect(markdown).toContain('**Subtitle:** A subtitle');
|
|
93
|
+
// Booleans render nothing — presentation toggles are not content.
|
|
94
|
+
expect(markdown).not.toContain('Featured');
|
|
95
|
+
expect(markdown).not.toContain('Keywords'); // empty group skipped, no heading
|
|
96
|
+
});
|
|
97
|
+
it('renders blocks content directly via the richtext seam — no heading', () => {
|
|
98
|
+
const markdown = documentToMarkdown({
|
|
99
|
+
...baseDoc,
|
|
100
|
+
fields: {
|
|
101
|
+
...baseDoc.fields,
|
|
102
|
+
content: [
|
|
103
|
+
{ _type: 'richTextBlock', richText: { root: { children: [] } } },
|
|
104
|
+
{ _type: 'richTextBlock', richText: { root: { children: [] } } },
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
}, definition, {
|
|
108
|
+
richTextToMarkdown: ({ fieldPath }) => `serialized(${fieldPath})`,
|
|
109
|
+
});
|
|
110
|
+
expect(markdown).toContain('serialized(richText)\n\nserialized(richText)');
|
|
111
|
+
expect(markdown).not.toContain('## Content');
|
|
112
|
+
});
|
|
113
|
+
it('skips richtext entirely when no serializer is registered', () => {
|
|
114
|
+
const markdown = documentToMarkdown({
|
|
115
|
+
...baseDoc,
|
|
116
|
+
fields: {
|
|
117
|
+
...baseDoc.fields,
|
|
118
|
+
content: [{ _type: 'richTextBlock', richText: { root: { children: [] } } }],
|
|
119
|
+
},
|
|
120
|
+
}, definition, {});
|
|
121
|
+
expect(markdown).not.toContain('serialized');
|
|
122
|
+
expect(markdown).not.toContain('[object');
|
|
123
|
+
});
|
|
124
|
+
it('renders populated relations as links via resolveUrl', () => {
|
|
125
|
+
const markdown = documentToMarkdown({
|
|
126
|
+
...baseDoc,
|
|
127
|
+
fields: {
|
|
128
|
+
...baseDoc.fields,
|
|
129
|
+
featureImage: {
|
|
130
|
+
targetDocumentId: 'm-1',
|
|
131
|
+
_resolved: true,
|
|
132
|
+
document: { path: 'hero-shot', fields: { title: 'Hero Shot' } },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
}, definition, { resolveUrl: (collection, path) => `/${collection}/${path}` });
|
|
136
|
+
expect(markdown).toContain('**Feature Image:** [Hero Shot](/media/hero-shot)');
|
|
137
|
+
});
|
|
138
|
+
it('renders unresolved relations as nothing', () => {
|
|
139
|
+
const markdown = documentToMarkdown({
|
|
140
|
+
...baseDoc,
|
|
141
|
+
fields: {
|
|
142
|
+
...baseDoc.fields,
|
|
143
|
+
featureImage: { targetDocumentId: 'gone', _resolved: false },
|
|
144
|
+
},
|
|
145
|
+
}, definition, {});
|
|
146
|
+
expect(markdown).not.toContain('Feature Image');
|
|
147
|
+
});
|
|
148
|
+
it('renders group fields as sections', () => {
|
|
149
|
+
const markdown = documentToMarkdown({ ...baseDoc, fields: { ...baseDoc.fields, meta: { keywords: 'cms, markdown' } } }, definition, {});
|
|
150
|
+
expect(markdown).toContain('## Meta\n\n**Keywords:** cms, markdown');
|
|
151
|
+
});
|
|
152
|
+
it('unwraps locale envelopes that reach the export untrimmed', () => {
|
|
153
|
+
const markdown = documentToMarkdown({ ...baseDoc, fields: { ...baseDoc.fields, title: { en: 'English', fr: 'Français' } } }, definition, { locale: 'fr' });
|
|
154
|
+
expect(markdown).toContain('# Français');
|
|
155
|
+
expect(markdown).not.toContain('[object');
|
|
156
|
+
});
|
|
157
|
+
it('escapes double quotes in frontmatter strings', () => {
|
|
158
|
+
const markdown = documentToMarkdown({ ...baseDoc, fields: { ...baseDoc.fields, title: 'The "Big" One' } }, definition, {});
|
|
159
|
+
expect(markdown).toContain('title: "The \\"Big\\" One"');
|
|
160
|
+
});
|
|
161
|
+
});
|
package/dist/services/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { type CollectionRecord, type EnsureCollectionsInput, ensureCollections,
|
|
|
5
5
|
export { type DiscoverCounterGroupsInput, discoverCounterGroups, } from './discover-counter-groups.js';
|
|
6
6
|
export * from './document-lifecycle/index.js';
|
|
7
7
|
export * from './document-read.js';
|
|
8
|
+
export * from './document-to-markdown.js';
|
|
8
9
|
export * from './field-upload.js';
|
|
9
10
|
export { type InterfaceI18nConfig, type TranslationDriftWarning, type ValidateTranslationsResult, validateTranslations, } from './i18n-validator.js';
|
|
10
11
|
export { type CycleRelationValue, createReadContext, type PopulatedRelationValue, type PopulateFieldOptions, type PopulateFieldSpec, type PopulateMap, type PopulateOptions, type PopulateSpec, populateDocuments, type ReadContext, type UnresolvedRelationValue, } from './populate.js';
|
package/dist/services/index.js
CHANGED
|
@@ -6,6 +6,7 @@ export { ensureCollections, } from './collection-bootstrap.js';
|
|
|
6
6
|
export { discoverCounterGroups, } from './discover-counter-groups.js';
|
|
7
7
|
export * from './document-lifecycle/index.js';
|
|
8
8
|
export * from './document-read.js';
|
|
9
|
+
export * from './document-to-markdown.js';
|
|
9
10
|
export * from './field-upload.js';
|
|
10
11
|
export { validateTranslations, } from './i18n-validator.js';
|
|
11
12
|
export { createReadContext, populateDocuments, } from './populate.js';
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@byline/core",
|
|
3
3
|
"private": false,
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
|
-
"version": "3.
|
|
5
|
+
"version": "3.8.0",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=20.9.0"
|
|
8
8
|
},
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"sharp": "^0.34.5",
|
|
80
80
|
"uuid": "^14.0.0",
|
|
81
81
|
"zod": "^4.4.3",
|
|
82
|
-
"@byline/auth": "3.
|
|
82
|
+
"@byline/auth": "3.8.0"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@biomejs/biome": "2.4.15",
|