@openparachute/vault 0.4.3 → 0.4.4-rc.12

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.
@@ -1,15 +1,41 @@
1
1
  /**
2
- * Obsidian vault parserreads .md files and extracts notes, tags, links.
2
+ * Obsidian-compatible markdown export/importback-compat shim.
3
3
  *
4
- * Handles:
5
- * - YAML frontmatter note.metadata
6
- * - Inline #tags and frontmatter tags tags table
7
- * - [[wikilinks]] handled by wikilinks.ts on note creation
8
- * - File path note.path
4
+ * @deprecated The canonical home for the markdown knowledge-base format
5
+ * is `portable-md.ts`. The format isn't Obsidian-specific (it's consumed
6
+ * unchanged by Logseq, Foam, Quartz, Dendron, and most markdown-shaped
7
+ * static-site generators) anchoring the function name to the format
8
+ * keeps the door open as other consumers adopt the same shape. See
9
+ * vault#308.
10
+ *
11
+ * What lives here:
12
+ * - `toObsidianMarkdown` — the **legacy** lossy emitter (flat
13
+ * frontmatter, no IDs, no typed links, no attachments). Kept for
14
+ * existing callers; for round-trippable exports use
15
+ * `toPortableMarkdown` in `portable-md.ts`.
16
+ * - `parseObsidianVault` / `parseObsidianFile` — directory + file
17
+ * parsers. These delegate to `portable-md.ts`'s parser, which
18
+ * handles both the new lossless shape and the legacy flat
19
+ * frontmatter shape.
20
+ * - Re-exports of `parseFrontmatter`, `extractInlineTags`,
21
+ * `walkMarkdownFiles` from `portable-md.ts` so existing imports
22
+ * keep working without code-level churn.
23
+ *
24
+ * New code should import from `portable-md.ts` directly.
9
25
  */
10
26
 
11
- import { readdirSync, readFileSync, statSync } from "fs";
12
- import { join, relative, extname, basename } from "path";
27
+ import { readFileSync } from "fs";
28
+ import { relative } from "path";
29
+
30
+ // Re-export the canonical parser helpers so existing callers (and tests)
31
+ // keep working against the legacy import path.
32
+ export {
33
+ parseFrontmatter,
34
+ extractInlineTags,
35
+ walkMarkdownFiles,
36
+ } from "./portable-md.js";
37
+
38
+ import { parseFrontmatter, walkMarkdownFiles, extractInlineTags } from "./portable-md.js";
13
39
 
14
40
  // ---------------------------------------------------------------------------
15
41
  // Types
@@ -34,121 +60,7 @@ export interface ImportStats {
34
60
  errors: { path: string; error: string }[];
35
61
  }
36
62
 
37
- // ---------------------------------------------------------------------------
38
- // Frontmatter parsing
39
- // ---------------------------------------------------------------------------
40
-
41
- /**
42
- * Parse YAML frontmatter from markdown content.
43
- * Returns { frontmatter, content } where content has frontmatter stripped.
44
- *
45
- * Uses a simple parser — no dependency on a YAML library.
46
- * Handles common frontmatter patterns: strings, arrays, numbers, booleans.
47
- */
48
- export function parseFrontmatter(raw: string): {
49
- frontmatter: Record<string, unknown>;
50
- content: string;
51
- } {
52
- if (!raw.startsWith("---")) {
53
- return { frontmatter: {}, content: raw };
54
- }
55
-
56
- const endIdx = raw.indexOf("\n---", 3);
57
- if (endIdx === -1) {
58
- return { frontmatter: {}, content: raw };
59
- }
60
-
61
- const yamlBlock = raw.slice(4, endIdx); // skip opening "---\n"
62
- const content = raw.slice(endIdx + 4).replace(/^\n/, ""); // skip closing "---\n"
63
-
64
- const frontmatter: Record<string, unknown> = {};
65
- let currentKey = "";
66
- let currentArray: string[] | null = null;
67
-
68
- for (const line of yamlBlock.split("\n")) {
69
- // Array item (continuation of previous key)
70
- if (currentArray !== null && /^\s+-\s+/.test(line)) {
71
- const val = line.replace(/^\s+-\s+/, "").trim();
72
- currentArray.push(unquote(val));
73
- continue;
74
- }
75
-
76
- // If we were building an array, save it (or save empty string if no items found)
77
- if (currentArray !== null) {
78
- frontmatter[currentKey] = currentArray.length > 0 ? currentArray : "";
79
- currentArray = null;
80
- }
81
-
82
- // Key: value pair — keys must be YAML-valid (word chars and hyphens, no spaces)
83
- const kvMatch = line.match(/^([\w][\w-]*):\s*(.*)/);
84
- if (kvMatch) {
85
- const key = kvMatch[1]!;
86
- const value = kvMatch[2]!.trim();
87
-
88
- if (value === "[]") {
89
- frontmatter[key] = [];
90
- } else if (value === "") {
91
- // Empty value: could be start of array (next lines are "- item")
92
- // or genuinely empty string. We start array accumulation and
93
- // handle the empty case when a non-array line follows.
94
- currentKey = key;
95
- currentArray = [];
96
- } else if (value.startsWith("[") && value.endsWith("]")) {
97
- // Inline array: [item1, item2]
98
- const items = value.slice(1, -1).split(",").map((s) => unquote(s.trim())).filter(Boolean);
99
- frontmatter[key] = items;
100
- } else {
101
- frontmatter[key] = parseValue(value);
102
- }
103
- }
104
- }
105
-
106
- // Save any trailing array (or empty string if no items)
107
- if (currentArray !== null) {
108
- frontmatter[currentKey] = currentArray.length > 0 ? currentArray : "";
109
- }
110
-
111
- return { frontmatter, content };
112
- }
113
-
114
- function unquote(s: string): string {
115
- if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
116
- return s.slice(1, -1);
117
- }
118
- return s;
119
- }
120
-
121
- function parseValue(s: string): unknown {
122
- s = unquote(s);
123
- if (s === "true") return true;
124
- if (s === "false") return false;
125
- if (s === "null") return null;
126
- if (/^-?\d+$/.test(s)) return parseInt(s, 10);
127
- if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s);
128
- return s;
129
- }
130
-
131
- // ---------------------------------------------------------------------------
132
- // Tag extraction
133
- // ---------------------------------------------------------------------------
134
-
135
- /** Extract inline #tags from markdown content. Excludes tags in code blocks. */
136
- export function extractInlineTags(content: string): string[] {
137
- // Strip code blocks and inline code
138
- let stripped = content.replace(/```[\s\S]*?```/g, "");
139
- stripped = stripped.replace(/`[^`\n]+`/g, "");
140
-
141
- const tags = new Set<string>();
142
- // Match #tag and #nested/tag — must be preceded by whitespace or start of line
143
- const regex = /(?:^|\s)#([\w][\w/-]*[\w]|[\w])/gm;
144
- let match: RegExpExecArray | null;
145
- while ((match = regex.exec(stripped)) !== null) {
146
- tags.add(match[1]!.toLowerCase());
147
- }
148
- return [...tags];
149
- }
150
-
151
- /** Extract tags from frontmatter (handles both array and string formats). */
63
+ /** Tags from frontmatter (handles both array and string formats). */
152
64
  function extractFrontmatterTags(frontmatter: Record<string, unknown>): string[] {
153
65
  const raw = frontmatter.tags;
154
66
  if (!raw) return [];
@@ -157,35 +69,6 @@ function extractFrontmatterTags(frontmatter: Record<string, unknown>): string[]
157
69
  return [];
158
70
  }
159
71
 
160
- // ---------------------------------------------------------------------------
161
- // Directory walking
162
- // ---------------------------------------------------------------------------
163
-
164
- /** Recursively list all .md files in a directory, excluding .obsidian/ and hidden dirs. */
165
- export function walkMarkdownFiles(dir: string): string[] {
166
- const results: string[] = [];
167
-
168
- function walk(current: string) {
169
- for (const entry of readdirSync(current)) {
170
- // Skip hidden directories and .obsidian config
171
- if (entry.startsWith(".")) continue;
172
- if (entry === "node_modules") continue;
173
-
174
- const full = join(current, entry);
175
- const stat = statSync(full);
176
-
177
- if (stat.isDirectory()) {
178
- walk(full);
179
- } else if (stat.isFile() && extname(entry).toLowerCase() === ".md") {
180
- results.push(full);
181
- }
182
- }
183
- }
184
-
185
- walk(dir);
186
- return results.sort();
187
- }
188
-
189
72
  // ---------------------------------------------------------------------------
190
73
  // Parse a single file
191
74
  // ---------------------------------------------------------------------------
@@ -207,12 +90,7 @@ export function parseObsidianFile(filePath: string, vaultRoot: string): Obsidian
207
90
  const metadata = { ...frontmatter };
208
91
  delete metadata.tags;
209
92
 
210
- return {
211
- path,
212
- content,
213
- frontmatter: metadata,
214
- tags: allTags,
215
- };
93
+ return { path, content, frontmatter: metadata, tags: allTags };
216
94
  }
217
95
 
218
96
  // ---------------------------------------------------------------------------
@@ -254,9 +132,13 @@ export function parseObsidianVault(vaultPath: string): {
254
132
  }
255
133
 
256
134
  // ---------------------------------------------------------------------------
257
- // Export to Obsidian format
135
+ // Legacy export kept for back-compat. New code: use `toPortableMarkdown`.
258
136
  // ---------------------------------------------------------------------------
259
137
 
138
+ /**
139
+ * Note shape the legacy export accepts. Distinct from `PortableNote` —
140
+ * older + lossy by design (no IDs, no typed links, no attachments).
141
+ */
260
142
  export interface ExportableNote {
261
143
  path?: string;
262
144
  id: string;
@@ -267,34 +149,33 @@ export interface ExportableNote {
267
149
  }
268
150
 
269
151
  /**
270
- * Convert a vault note to Obsidian-compatible markdown with YAML frontmatter.
152
+ * Convert a vault note to Obsidian-compatible markdown with YAML
153
+ * frontmatter — legacy flat-frontmatter shape (metadata keys at the
154
+ * top level, no IDs, no typed links, no attachments).
155
+ *
156
+ * @deprecated Prefer `toPortableMarkdown` in `portable-md.ts` for new
157
+ * code. This function is preserved for callers that intentionally want
158
+ * the legacy lossy shape — typically one-shot "give me an Obsidian
159
+ * copy" exports without round-trip concerns. See vault#308.
271
160
  */
272
161
  export function toObsidianMarkdown(note: ExportableNote): string {
273
162
  const fm: Record<string, unknown> = {};
274
163
 
275
- // Add tags to frontmatter
276
- if (note.tags && note.tags.length > 0) {
277
- fm.tags = note.tags;
278
- }
279
-
280
- // Add metadata fields (excluding internal ones)
164
+ if (note.tags && note.tags.length > 0) fm.tags = note.tags;
281
165
  if (note.metadata) {
282
166
  for (const [key, value] of Object.entries(note.metadata)) {
283
- if (key === "tags") continue; // already handled
167
+ if (key === "tags") continue;
284
168
  fm[key] = value;
285
169
  }
286
170
  }
287
171
 
288
- // Build frontmatter string
289
172
  let result = "";
290
173
  if (Object.keys(fm).length > 0) {
291
174
  result += "---\n";
292
175
  for (const [key, value] of Object.entries(fm)) {
293
176
  if (Array.isArray(value)) {
294
177
  result += `${key}:\n`;
295
- for (const item of value) {
296
- result += ` - ${item}\n`;
297
- }
178
+ for (const item of value) result += ` - ${item}\n`;
298
179
  } else if (typeof value === "object" && value !== null) {
299
180
  result += `${key}: ${JSON.stringify(value)}\n`;
300
181
  } else {
@@ -309,14 +190,11 @@ export function toObsidianMarkdown(note: ExportableNote): string {
309
190
  }
310
191
 
311
192
  /**
312
- * Determine the file path for an exported note.
313
- * Notes with paths use the path; pathless notes use date/id.
193
+ * Determine the file path for an exported note (legacy form).
194
+ * @deprecated Use `portableExportFilePath` from `portable-md.ts`.
314
195
  */
315
196
  export function exportFilePath(note: ExportableNote): string {
316
- if (note.path) {
317
- return note.path + ".md";
318
- }
319
- // Fallback: use date prefix + truncated id
197
+ if (note.path) return note.path + ".md";
320
198
  const date = note.createdAt.split("T")[0];
321
199
  return `${date}/${note.id}.md`;
322
200
  }