@byline/core 3.6.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  }
@@ -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.7.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.7.0"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@biomejs/biome": "2.4.15",