@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.
@@ -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` → `![alt](url)`.
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` → `![alt](url)`.
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
+ });
@@ -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';
@@ -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.6.0",
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.6.0"
82
+ "@byline/auth": "3.8.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",