@abraca/convert 2.23.0 → 2.24.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/abracadabra-convert.cjs +3101 -2926
- package/dist/abracadabra-convert.cjs.map +1 -1
- package/dist/abracadabra-convert.esm.js +3101 -2927
- package/dist/abracadabra-convert.esm.js.map +1 -1
- package/dist/index.d.ts +14 -1
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/markdown-to-yjs.ts +109 -4
- package/src/yjs-to-markdown.ts +152 -53
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
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,
|
package/src/markdown-to-yjs.ts
CHANGED
|
@@ -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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/yjs-to-markdown.ts
CHANGED
|
@@ -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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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 (
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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 (
|
|
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
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
-
|
|
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
|
/**
|