@abraca/convert 2.23.0 → 2.25.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/index.d.ts CHANGED
@@ -97,6 +97,19 @@ declare function populateYDocFromMarkdown(fragment: Y.XmlFragment, markdown: str
97
97
  //#endregion
98
98
  //#region packages/convert/src/yjs-to-markdown.d.ts
99
99
  declare function yjsToMarkdown(fragment: Y.XmlFragment, label: string, meta?: DocPageMeta, type?: string): string;
100
+ /**
101
+ * Make a serialised doc self-describing for an Obsidian-style vault or a
102
+ * markdown export: the doc's label is always present as a leading `# Title`,
103
+ * even when the body is empty (containers, kanban columns) — that's how the
104
+ * app shows it, and it makes the label round-trip losslessly (the parser
105
+ * maps a leading H1 → the doc title) with zero sidecar dependency.
106
+ *
107
+ * Pure post-process of yjsToMarkdown's output; the serializer's own
108
+ * byte-stability invariants are untouched. Originally lived in cou-sh's
109
+ * fs-sync (useFsSyncTo) — lifted here so fs-sync and doc export share one
110
+ * implementation.
111
+ */
112
+ declare function ensureLeadingTitle(md: string, label: unknown): string;
100
113
  /**
101
114
  * Walk the Y.XmlFragment and concatenate all text content with no
102
115
  * markup or frontmatter. Block boundaries become newlines. Useful for
@@ -408,4 +421,4 @@ declare function getTreeData(treeMap: any): Record<string, FsTreeEntry>;
408
421
  */
409
422
  declare function nextOrder(treeData: Record<string, FsTreeEntry>, parentId: string | null): number;
410
423
  //#endregion
411
- export { CODE_TEXT_KEY, type DocPageMeta, type FrontmatterResult, type FsAdapter, type FsSyncManifest, type FsTreeEntry, type JsonAttr, MARK_SPECS, MARK_SPEC_BY_DELIM, MARK_SPEC_BY_NAME, type ManifestEntry, type MarkAttrSpec, type MarkSpec, type MarkWireKind, type MetaValueType, NODE_SPECS, NODE_SPEC_BY_NAME, type NodeAttrSpec, type NodeSpec, type NodeWireKind, UNIVERSAL_META_KEYS, UNIVERSAL_META_KEY_NAMES, type UniversalMetaKey, type UploadManifestEntry, type YjsDiff, type YjsJsonElement, type YjsJsonNode, type YjsJsonText, type YjsTextRun, appendHtmlToFragment, buildAliasMap, buildRelativePath, buildReverseLookup, codeCompanionPath, codeFileExtension, conflictsDir, createEmptyManifest, diffJson, diffYjs, extToLanguage, filenameToLabel, fsFilenameToLabel, getDocDir, getFsAdapter, getTreeData, hasChildren, inferCodeMetaFromFilename, isCodeExtension, jsonToYFragment, labelToFilename, languageToExtension, loadManifest, lookupByDocId, lookupByHash, lookupByPath, manifestDir, mdcTagOf, nextOrder, normalizeExtension, orphansDir, parseFrontmatter, populateYDocFromHtml, populateYDocFromMarkdown, readCodeText, removeEntry, resolveParentFromPath, saveManifest, setEntry, setFsAdapter, simpleHash, stringifyJsonNode, trashDir, writeCodeText, yfragmentToJson, yjsToHtml, yjsToMarkdown, yjsToPlainText };
424
+ export { CODE_TEXT_KEY, type DocPageMeta, type FrontmatterResult, type FsAdapter, type FsSyncManifest, type FsTreeEntry, type JsonAttr, MARK_SPECS, MARK_SPEC_BY_DELIM, MARK_SPEC_BY_NAME, type ManifestEntry, type MarkAttrSpec, type MarkSpec, type MarkWireKind, type MetaValueType, NODE_SPECS, NODE_SPEC_BY_NAME, type NodeAttrSpec, type NodeSpec, type NodeWireKind, UNIVERSAL_META_KEYS, UNIVERSAL_META_KEY_NAMES, type UniversalMetaKey, type UploadManifestEntry, type YjsDiff, type YjsJsonElement, type YjsJsonNode, type YjsJsonText, type YjsTextRun, appendHtmlToFragment, buildAliasMap, buildRelativePath, buildReverseLookup, codeCompanionPath, codeFileExtension, conflictsDir, createEmptyManifest, diffJson, diffYjs, ensureLeadingTitle, extToLanguage, filenameToLabel, fsFilenameToLabel, getDocDir, getFsAdapter, getTreeData, hasChildren, inferCodeMetaFromFilename, isCodeExtension, jsonToYFragment, labelToFilename, languageToExtension, loadManifest, lookupByDocId, lookupByHash, lookupByPath, manifestDir, mdcTagOf, nextOrder, normalizeExtension, orphansDir, parseFrontmatter, populateYDocFromHtml, populateYDocFromMarkdown, readCodeText, removeEntry, resolveParentFromPath, saveManifest, setEntry, setFsAdapter, simpleHash, stringifyJsonNode, trashDir, writeCodeText, yfragmentToJson, yjsToHtml, yjsToMarkdown, yjsToPlainText };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abraca/convert",
3
- "version": "2.23.0",
3
+ "version": "2.25.0",
4
4
  "description": "Bullet-proof Markdown ↔ Yjs/TipTap round-trip — canonical converter shared by cou-sh, abracadabra-nuxt, and @abraca/mcp.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -15,7 +15,7 @@ export {
15
15
  type FrontmatterResult,
16
16
  } from './markdown-to-yjs.ts'
17
17
 
18
- export { yjsToMarkdown, yjsToHtml, yjsToPlainText } from './yjs-to-markdown.ts'
18
+ export { yjsToMarkdown, yjsToHtml, yjsToPlainText, ensureLeadingTitle } from './yjs-to-markdown.ts'
19
19
 
20
20
  export {
21
21
  populateYDocFromHtml,
@@ -1,5 +1,11 @@
1
1
  import * as Y from 'yjs'
2
2
  import type { DocPageMeta } from './types.ts'
3
+ import {
4
+ buildAliasMap,
5
+ UNIVERSAL_META_KEYS,
6
+ type UniversalMetaKey,
7
+ type MetaValueType,
8
+ } from './spec/universal-meta.ts'
3
9
 
4
10
  // ── Filename → readable label ────────────────────────────────────────────────
5
11
 
@@ -160,6 +166,28 @@ export function parseFrontmatter(markdown: string): FrontmatterResult {
160
166
  if (!Number.isNaN(n)) meta.rating = Math.min(5, Math.max(0, n))
161
167
  }
162
168
 
169
+ // ── Generic spec-driven pass ──
170
+ // Everything the hand-rolled section above didn't consume: map through
171
+ // the universal-meta alias map and coerce by the spec's value type, so
172
+ // graph/map/spatial/dashboard layout, covers, datetimes etc. survive
173
+ // import instead of being silently dropped. Unknown custom keys are
174
+ // preserved with best-effort coercion; internal `_`-keys are ignored.
175
+ for (const [rawKey, rawVal] of Object.entries(raw)) {
176
+ if (CONSUMED_FM_KEYS.has(rawKey)) continue
177
+ if (rawKey.startsWith('_')) continue
178
+ const canonical = FM_ALIAS_MAP.get(rawKey)
179
+ if (canonical) {
180
+ if (canonical === 'title' || canonical === 'type') continue
181
+ if ((meta as Record<string, unknown>)[canonical] !== undefined) continue
182
+ const spec = FM_SPEC_BY_KEY.get(canonical)
183
+ const coerced = coerceMetaValue(rawVal, spec?.type)
184
+ if (coerced !== undefined) (meta as Record<string, unknown>)[canonical] = coerced
185
+ } else {
186
+ const coerced = coerceMetaValue(rawVal, undefined)
187
+ if (coerced !== undefined) (meta as Record<string, unknown>)[rawKey] = coerced
188
+ }
189
+ }
190
+
163
191
  const rawTitle = typeof raw['title'] === 'string' ? raw['title'] : undefined
164
192
  const title = rawTitle !== undefined ? stripQuotes(rawTitle) : undefined
165
193
  const type = getStr(['type'])
@@ -167,6 +195,72 @@ export function parseFrontmatter(markdown: string): FrontmatterResult {
167
195
  return { title, type, meta, body }
168
196
  }
169
197
 
198
+ /** Raw YAML keys the hand-rolled section of parseFrontmatter consumes. */
199
+ const CONSUMED_FM_KEYS: ReadonlySet<string> = new Set([
200
+ 'title', 'type', 'tags', 'color', 'icon', 'status', 'priority',
201
+ 'checked', 'done', 'dateStart', 'date', 'created', 'dateEnd', 'due',
202
+ 'subtitle', 'description', 'url', 'language', 'fileExtension',
203
+ 'codeTheme', 'rating',
204
+ ])
205
+
206
+ const FM_ALIAS_MAP: ReadonlyMap<string, string> = buildAliasMap()
207
+ const FM_SPEC_BY_KEY: ReadonlyMap<string, UniversalMetaKey> = new Map(
208
+ UNIVERSAL_META_KEYS.map(k => [k.key, k]),
209
+ )
210
+
211
+ /**
212
+ * Coerce a raw YAML scalar/array to the spec's value type. With no spec
213
+ * (custom key) the coercion is best-effort: booleans and numbers are
214
+ * recognised by shape, everything else stays a string. Returns undefined
215
+ * when the value can't be represented (caller skips the key).
216
+ */
217
+ function coerceMetaValue(
218
+ rawVal: string | string[],
219
+ specType: MetaValueType | undefined,
220
+ ): unknown {
221
+ if (Array.isArray(rawVal)) return rawVal
222
+ const v = rawVal
223
+ if (v === '') return undefined
224
+ switch (specType) {
225
+ case 'number':
226
+ case 'integer': {
227
+ const n = Number(v)
228
+ return Number.isFinite(n) ? n : undefined
229
+ }
230
+ case 'boolean':
231
+ return v === 'true'
232
+ case 'string[]':
233
+ return [v]
234
+ case 'members':
235
+ case 'json': {
236
+ try {
237
+ return JSON.parse(v)
238
+ } catch {
239
+ return undefined
240
+ }
241
+ }
242
+ case 'string':
243
+ case 'string-enum':
244
+ case 'iso-date':
245
+ case 'iso-datetime':
246
+ case 'hh-mm':
247
+ return v
248
+ case undefined: {
249
+ // Custom key — recognise booleans/numbers/JSON payloads by shape
250
+ // (quotes were already stripped when the raw map was built).
251
+ if (v === 'true') return true
252
+ if (v === 'false') return false
253
+ if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v)
254
+ if (v.startsWith('{')) {
255
+ try {
256
+ return JSON.parse(v)
257
+ } catch { /* fall through to string */ }
258
+ }
259
+ return v
260
+ }
261
+ }
262
+ }
263
+
170
264
  // ── Inline token parsing ─────────────────────────────────────────────────────
171
265
 
172
266
  interface InlineToken {
@@ -444,6 +538,7 @@ function consumeList(
444
538
 
445
539
  // Consume continuation: lines indented deeper than `indent`.
446
540
  const contLines: string[] = []
541
+ let contMinIndent = Infinity
447
542
  while (i < lines.length) {
448
543
  const next = lines[i]!
449
544
  if (next.trim() === '') {
@@ -461,13 +556,23 @@ function consumeList(
461
556
  }
462
557
  const nextIndent = leadingSpaces(next)
463
558
  if (nextIndent <= indent) break
464
- // De-indent by exactly 2 to match the canonical wire form.
465
- const deindentBy = Math.min(nextIndent, indent + 2)
466
- contLines.push(next.slice(deindentBy))
559
+ if (nextIndent < contMinIndent) contMinIndent = nextIndent
560
+ contLines.push(next)
467
561
  i++
468
562
  }
469
563
  if (contLines.length > 0) {
470
- item.innerBlocks = parseBlocks(contLines.join('\n'))
564
+ // De-indent the whole continuation block by its common minimum
565
+ // indent (not a fixed `indent + 2`): CommonMark nests 3 columns
566
+ // under `1. ` markers, so clamping at 2 left a 1-space residue
567
+ // that parseBlocks read as paragraph text — merging nested list
568
+ // items onto a single line. Stripping the common indent keeps
569
+ // relative structure for deeper levels and lands the first nested
570
+ // marker at column 0, where parseBlocks recognises it. Canonical
571
+ // wire-form input (exactly `indent + 2`) strips identically.
572
+ const deindentBy = contMinIndent === Infinity ? 0 : contMinIndent
573
+ item.innerBlocks = parseBlocks(
574
+ contLines.map(l => l.slice(deindentBy)).join('\n')
575
+ )
471
576
  }
472
577
  items.push(item)
473
578
  }
@@ -1,5 +1,10 @@
1
1
  import * as Y from 'yjs'
2
2
  import type { DocPageMeta } from './types.ts'
3
+ import {
4
+ UNIVERSAL_META_KEYS,
5
+ UNIVERSAL_META_KEY_NAMES,
6
+ type MetaValueType,
7
+ } from './spec/universal-meta.ts'
3
8
 
4
9
  // ── Cross-yjs-copy node typing ───────────────────────────────────────────────
5
10
  //
@@ -357,25 +362,24 @@ function serializeListItems(el: Y.XmlElement, type: 'bullet' | 'ordered', indent
357
362
  if (!(isXElem(child)) || child.nodeName !== 'listItem') continue
358
363
  const prefix = type === 'bullet' ? '- ' : `${counter++}. `
359
364
  // A listItem may contain paragraphs and nested lists
360
- const subParts: string[] = []
365
+ const subParts: Array<{ text: string, isList: boolean }> = []
361
366
  for (const sub of child.toArray()) {
362
367
  if (!(isXElem(sub))) continue
363
368
  if (sub.nodeName === 'bulletList') {
364
- subParts.push(serializeListItems(sub, 'bullet', indent + ' '))
369
+ subParts.push({ text: serializeListItems(sub, 'bullet', indent + ' '), isList: true })
365
370
  } else if (sub.nodeName === 'orderedList') {
366
- subParts.push(serializeListItems(sub, 'ordered', indent + ' '))
371
+ subParts.push({ text: serializeListItems(sub, 'ordered', indent + ' '), isList: true })
367
372
  } else {
368
- subParts.push(serializeInline(sub))
373
+ subParts.push({ text: serializeInline(sub), isList: false })
369
374
  }
370
375
  }
371
- if (subParts.length <= 1) {
372
- lines.push(`${indent}${prefix}${subParts[0] ?? ''}`)
373
- } else {
374
- lines.push(`${indent}${prefix}${subParts[0] ?? ''}`)
375
- for (let i = 1; i < subParts.length; i++) {
376
- // Nested lists are already indented; plain text gets continuation indent
377
- lines.push(subParts[i]!)
378
- }
376
+ lines.push(`${indent}${prefix}${subParts[0]?.text ?? ''}`)
377
+ for (let i = 1; i < subParts.length; i++) {
378
+ const part = subParts[i]!
379
+ // Nested lists are already indented; plain-text continuation gets
380
+ // the item's content indent so it stays inside the listItem on
381
+ // re-parse instead of escaping as a sibling paragraph.
382
+ lines.push(part.isList ? part.text : `${indent} ${part.text}`)
379
383
  }
380
384
  }
381
385
  return lines.join('\n')
@@ -479,6 +483,52 @@ function serializeSlottedContainer(
479
483
 
480
484
  // ── Frontmatter generation ──────────────────────────────────────────────────
481
485
 
486
+ /**
487
+ * Keys the hand-rolled canonical section below emits itself. The generic
488
+ * spec-driven pass must skip these so each key is serialised exactly once,
489
+ * in the canonical order existing fixtures depend on.
490
+ */
491
+ const CANONICAL_FM_KEYS: ReadonlySet<string> = new Set([
492
+ 'title', 'type', 'tags', 'color', 'icon', 'status', 'priority', 'checked',
493
+ 'language', 'fileExtension', 'codeTheme', 'dateStart', 'dateEnd',
494
+ 'subtitle', 'url', 'rating',
495
+ ])
496
+
497
+ /** Render one meta value as a YAML line, or null when it can't be emitted. */
498
+ function yamlLine(key: string, v: unknown, specType?: MetaValueType): string | null {
499
+ if (v === undefined || v === null) return null
500
+ if (Array.isArray(v)) {
501
+ if (v.length === 0) return null
502
+ return `${key}: [${v.map(x => String(x)).join(', ')}]`
503
+ }
504
+ if (typeof v === 'number') return Number.isFinite(v) ? `${key}: ${v}` : null
505
+ if (typeof v === 'boolean') return `${key}: ${v}`
506
+ if (typeof v === 'string') return v === '' ? null : `${key}: ${yamlScalar(v)}`
507
+ if (specType === 'members' || specType === 'json' || typeof v === 'object') {
508
+ // Structured value — emit as single-quoted JSON (parse side JSON.parses
509
+ // it back). Skip when the payload itself contains a single quote rather
510
+ // than emit un-round-trippable YAML.
511
+ try {
512
+ const json = JSON.stringify(v)
513
+ return json.includes('\'') ? null : `${key}: '${json}'`
514
+ } catch {
515
+ return null
516
+ }
517
+ }
518
+ return null
519
+ }
520
+
521
+ /**
522
+ * Generate the YAML frontmatter block. Returns '' when there is nothing to
523
+ * emit, so callers can skip the block entirely — an empty `---\n\n---` shell
524
+ * is never produced (docs whose meta holds only internal `_`-keys used to
525
+ * export as exactly that junk).
526
+ *
527
+ * Emission order: the long-standing hand-rolled canonical keys first (byte
528
+ * stability for existing files), then every remaining universal-meta key in
529
+ * registry order, then custom keys alphabetically. Internal keys (leading
530
+ * `_`, e.g. `_metaInitialized`) are never serialised.
531
+ */
482
532
  function generateFrontmatter(label: string | undefined, meta?: DocPageMeta, type?: string): string {
483
533
  const lines: string[] = []
484
534
 
@@ -488,32 +538,54 @@ function generateFrontmatter(label: string | undefined, meta?: DocPageMeta, type
488
538
  lines.push(`type: ${type}`)
489
539
  }
490
540
 
491
- if (!meta) return `---\n${lines.join('\n')}\n---`
492
-
493
- if (meta.tags?.length) {
494
- lines.push(`tags: [${meta.tags.join(', ')}]`)
495
- }
496
- if (meta.color) lines.push(`color: ${yamlScalar(meta.color)}`)
497
- if (meta.icon) lines.push(`icon: ${yamlScalar(meta.icon)}`)
498
- if (meta.status) lines.push(`status: ${yamlScalar(meta.status)}`)
499
-
500
- if (meta.priority !== undefined && meta.priority !== 0) {
501
- const map: Record<number, string> = { 1: 'low', 2: 'medium', 3: 'high', 4: 'urgent' }
502
- lines.push(`priority: ${map[meta.priority] ?? meta.priority}`)
541
+ if (meta) {
542
+ if (meta.tags?.length) {
543
+ lines.push(`tags: [${meta.tags.join(', ')}]`)
544
+ }
545
+ if (meta.color) lines.push(`color: ${yamlScalar(meta.color)}`)
546
+ if (meta.icon) lines.push(`icon: ${yamlScalar(meta.icon)}`)
547
+ if (meta.status) lines.push(`status: ${yamlScalar(meta.status)}`)
548
+
549
+ if (meta.priority !== undefined && meta.priority !== 0) {
550
+ const map: Record<number, string> = { 1: 'low', 2: 'medium', 3: 'high', 4: 'urgent' }
551
+ lines.push(`priority: ${map[meta.priority] ?? meta.priority}`)
552
+ }
553
+
554
+ if (meta.checked !== undefined) lines.push(`checked: ${meta.checked}`)
555
+ // Code page type: syntax-highlight language + on-disk extension. These
556
+ // round-trip the `.md` envelope so the companion code file can be re-derived.
557
+ if (meta.language) lines.push(`language: ${yamlScalar(meta.language)}`)
558
+ if (meta.fileExtension) lines.push(`fileExtension: ${yamlScalar(meta.fileExtension)}`)
559
+ if (meta.codeTheme) lines.push(`codeTheme: ${yamlScalar(meta.codeTheme)}`)
560
+ if (meta.dateStart) lines.push(`dateStart: "${escapeYaml(meta.dateStart)}"`)
561
+ if (meta.dateEnd) lines.push(`dateEnd: "${escapeYaml(meta.dateEnd)}"`)
562
+ if (meta.subtitle) lines.push(`subtitle: "${escapeYaml(meta.subtitle)}"`)
563
+ if (meta.url) lines.push(`url: ${meta.url}`)
564
+ if (meta.rating !== undefined && meta.rating !== 0) lines.push(`rating: ${meta.rating}`)
565
+
566
+ // Spec-driven pass: every other universal key, in registry order, so
567
+ // graph/map/spatial/dashboard layout, covers, datetimes etc. survive
568
+ // export instead of being silently dropped.
569
+ const m = meta as Record<string, unknown>
570
+ for (const spec of UNIVERSAL_META_KEYS) {
571
+ if (CANONICAL_FM_KEYS.has(spec.key)) continue
572
+ const line = yamlLine(spec.key, m[spec.key], spec.type)
573
+ if (line) lines.push(line)
574
+ }
575
+
576
+ // Custom (non-spec) keys, alphabetically; internal `_`-keys skipped.
577
+ const customKeys = Object.keys(m)
578
+ .filter(k => !k.startsWith('_')
579
+ && !CANONICAL_FM_KEYS.has(k)
580
+ && !UNIVERSAL_META_KEY_NAMES.has(k))
581
+ .sort()
582
+ for (const k of customKeys) {
583
+ const line = yamlLine(k, m[k])
584
+ if (line) lines.push(line)
585
+ }
503
586
  }
504
587
 
505
- if (meta.checked !== undefined) lines.push(`checked: ${meta.checked}`)
506
- // Code page type: syntax-highlight language + on-disk extension. These
507
- // round-trip the `.md` envelope so the companion code file can be re-derived.
508
- if (meta.language) lines.push(`language: ${yamlScalar(meta.language)}`)
509
- if (meta.fileExtension) lines.push(`fileExtension: ${yamlScalar(meta.fileExtension)}`)
510
- if (meta.codeTheme) lines.push(`codeTheme: ${yamlScalar(meta.codeTheme)}`)
511
- if (meta.dateStart) lines.push(`dateStart: "${escapeYaml(meta.dateStart)}"`)
512
- if (meta.dateEnd) lines.push(`dateEnd: "${escapeYaml(meta.dateEnd)}"`)
513
- if (meta.subtitle) lines.push(`subtitle: "${escapeYaml(meta.subtitle)}"`)
514
- if (meta.url) lines.push(`url: ${meta.url}`)
515
- if (meta.rating !== undefined && meta.rating !== 0) lines.push(`rating: ${meta.rating}`)
516
-
588
+ if (lines.length === 0) return ''
517
589
  return `---\n${lines.join('\n')}\n---`
518
590
  }
519
591
 
@@ -714,9 +786,6 @@ export function yjsToMarkdown(
714
786
  const effectiveMeta = meta ?? docMeta.meta
715
787
  const effectiveType = type ?? docMeta.type
716
788
 
717
- const metaIsEmpty = isMetaEmpty(effectiveMeta)
718
- const typeIsDefault = !effectiveType || effectiveType === 'doc'
719
-
720
789
  const bodyBlocks = collectBodyBlocks(fragment)
721
790
 
722
791
  // Body assembly — when the parser captured the title from a body H1
@@ -730,13 +799,13 @@ export function yjsToMarkdown(
730
799
  body = serializeBlocksClean(bodyBlocks)
731
800
  }
732
801
 
733
- const wantFrontmatterTitle = titleSource === 'frontmatter'
734
- const wantFrontmatterMeta = !metaIsEmpty || !typeIsDefault
735
- if (!wantFrontmatterTitle && !wantFrontmatterMeta) {
802
+ const fmTitle = titleSource === 'frontmatter' ? effectiveTitle : undefined
803
+ // The generator returns '' when there is nothing to emit — never an
804
+ // empty `---\n\n---` shell (e.g. meta holding only internal `_`-keys).
805
+ const frontmatter = generateFrontmatter(fmTitle, effectiveMeta, effectiveType)
806
+ if (frontmatter === '') {
736
807
  return body === '' ? '' : `${body}\n`
737
808
  }
738
- const fmTitle = wantFrontmatterTitle ? effectiveTitle : undefined
739
- const frontmatter = generateFrontmatter(fmTitle, effectiveMeta, effectiveType)
740
809
  if (body === '') return `${frontmatter}\n`
741
810
  return `${frontmatter}\n\n${body}\n`
742
811
  }
@@ -800,16 +869,46 @@ function serializeBlocksClean(blocks: Y.XmlElement[]): string {
800
869
  return parts.join('\n\n')
801
870
  }
802
871
 
803
- function isMetaEmpty(meta: DocPageMeta | undefined): boolean {
804
- if (!meta) return true
805
- for (const key of Object.keys(meta)) {
806
- const v = (meta as Record<string, unknown>)[key]
807
- if (v === undefined || v === null) continue
808
- if (typeof v === 'string' && v === '') continue
809
- if (Array.isArray(v) && v.length === 0) continue
810
- return false
872
+ /**
873
+ * Make a serialised doc self-describing for an Obsidian-style vault or a
874
+ * markdown export: the doc's label is always present as a leading `# Title`,
875
+ * even when the body is empty (containers, kanban columns) — that's how the
876
+ * app shows it, and it makes the label round-trip losslessly (the parser
877
+ * maps a leading H1 the doc title) with zero sidecar dependency.
878
+ *
879
+ * Pure post-process of yjsToMarkdown's output; the serializer's own
880
+ * byte-stability invariants are untouched. Originally lived in cou-sh's
881
+ * fs-sync (useFsSyncTo) — lifted here so fs-sync and doc export share one
882
+ * implementation.
883
+ */
884
+ export function ensureLeadingTitle(md: string, label: unknown): string {
885
+ const title = typeof label === 'string' ? label.trim() : ''
886
+
887
+ // Peel an optional leading frontmatter block off the body.
888
+ let fm = ''
889
+ let body = md
890
+ const fmMatch = /^---\n([\s\S]*?)\n---\n?/.exec(md)
891
+ if (fmMatch) {
892
+ body = md.slice(fmMatch[0].length)
893
+ // Collapse an empty frontmatter (only whitespace between fences).
894
+ fm = (fmMatch[1] ?? '').trim() === '' ? '' : `---\n${fmMatch[1]}\n---`
895
+ }
896
+ body = body.replace(/^\n+/, '')
897
+
898
+ if (!title) {
899
+ const out = fm ? (body ? `${fm}\n\n${body}` : `${fm}\n`) : body
900
+ return out && !out.endsWith('\n') ? `${out}\n` : out
811
901
  }
812
- return true
902
+
903
+ // A leading ATX H1 is already the title (the serializer emitted
904
+ // headerText||label via titleSource:'h1') — leave it, never double it.
905
+ const hasH1 = /^#[ \t]+\S/.test(body)
906
+ let out = hasH1
907
+ ? body
908
+ : (body ? `# ${title}\n\n${body}` : `# ${title}\n`)
909
+ if (fm) out = `${fm}\n\n${out}`
910
+ if (!out.endsWith('\n')) out += '\n'
911
+ return out
813
912
  }
814
913
 
815
914
  /**