@gmickel/gno 0.34.0 → 0.35.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/README.md CHANGED
@@ -427,11 +427,14 @@ Open `http://localhost:3000` to:
427
427
  - **Search**: BM25, vector, or hybrid modes with visual results
428
428
  - **Browse**: Cross-collection tree workspace with folder detail panes and per-tab browse context
429
429
  - **Edit**: Create, edit, and delete documents with live preview
430
+ - **Create in place**: New notes in the current folder/collection with presets and command-palette flows
430
431
  - **Ask**: AI-powered Q&A with citations
431
432
  - **Manage Collections**: Add, remove, and re-index collections
432
433
  - **Connect agents**: Install core Skill/MCP integrations from the app
433
434
  - **Manage files safely**: Rename, reveal, or move editable files to Trash with explicit index-vs-disk semantics
435
+ - **Refactor files safely**: Move, duplicate, and organize editable notes with reference warnings
434
436
  - **Switch presets**: Change models live without restart
437
+ - **Command palette**: Jump, create, refactor, and section-navigate from one keyboard-first surface
435
438
 
436
439
  ### Search
437
440
 
@@ -445,19 +448,20 @@ Three retrieval modes: BM25 (keyword), Vector (semantic), or Hybrid (best of bot
445
448
 
446
449
  Full-featured markdown editor with:
447
450
 
448
- | Feature | Description |
449
- | :---------------------- | :----------------------------------- |
450
- | **Split View** | Side-by-side editor and live preview |
451
- | **Auto-save** | 2-second debounced saves |
452
- | **Syntax Highlighting** | CodeMirror 6 with markdown support |
453
- | **Keyboard Shortcuts** | ⌘S save, ⌘B bold, ⌘I italic, ⌘K link |
454
- | **Quick Capture** | ⌘N creates new note from anywhere |
451
+ | Feature | Description |
452
+ | :---------------------- | :------------------------------------------- |
453
+ | **Split View** | Side-by-side editor and live preview |
454
+ | **Auto-save** | 2-second debounced saves |
455
+ | **Syntax Highlighting** | CodeMirror 6 with markdown support |
456
+ | **Keyboard Shortcuts** | ⌘S save, ⌘B bold, ⌘I italic, ⌘K link |
457
+ | **Quick Capture** | ⌘N creates new note from anywhere |
458
+ | **Presets** | Structured note scaffolds and insert actions |
455
459
 
456
460
  ### Document Viewer
457
461
 
458
462
  ![GNO Document Viewer](./assets/screenshots/webui-doc-view.jpg)
459
463
 
460
- View documents with full context: outgoing links, backlinks, and AI-powered related notes sidebar.
464
+ View documents with full context: outgoing links, backlinks, section outline, and AI-powered related notes sidebar.
461
465
 
462
466
  ### Browse Workspace
463
467
 
@@ -467,6 +471,7 @@ Navigate your notes like a real workspace, not just a flat list:
467
471
 
468
472
  - Cross-collection tree sidebar
469
473
  - Folder detail panes
474
+ - Create note and create folder from current browse context
470
475
  - Pinned collections and per-tab browse state
471
476
  - Direct jump from folder structure into notes
472
477
 
@@ -516,24 +521,30 @@ curl -X POST http://localhost:3000/api/ask \
516
521
  curl http://localhost:3000/api/status
517
522
  ```
518
523
 
519
- | Endpoint | Method | Description |
520
- | :------------------------- | :----- | :-------------------------- |
521
- | `/api/query` | POST | Hybrid search (recommended) |
522
- | `/api/search` | POST | BM25 keyword search |
523
- | `/api/ask` | POST | AI-powered Q&A |
524
- | `/api/docs` | GET | List documents |
525
- | `/api/docs` | POST | Create document |
526
- | `/api/docs/:id` | PUT | Update document content |
527
- | `/api/docs/:id/deactivate` | POST | Remove from index |
528
- | `/api/doc` | GET | Get document content |
529
- | `/api/collections` | POST | Add collection |
530
- | `/api/collections/:name` | DELETE | Remove collection |
531
- | `/api/sync` | POST | Trigger re-index |
532
- | `/api/status` | GET | Index statistics |
533
- | `/api/presets` | GET | List model presets |
534
- | `/api/presets` | POST | Switch preset |
535
- | `/api/models/pull` | POST | Download models |
536
- | `/api/models/status` | GET | Download progress |
524
+ | Endpoint | Method | Description |
525
+ | :---------------------------- | :----- | :-------------------------- |
526
+ | `/api/query` | POST | Hybrid search (recommended) |
527
+ | `/api/search` | POST | BM25 keyword search |
528
+ | `/api/ask` | POST | AI-powered Q&A |
529
+ | `/api/docs` | GET | List documents |
530
+ | `/api/docs` | POST | Create document |
531
+ | `/api/docs/:id` | PUT | Update document content |
532
+ | `/api/docs/:id/move` | POST | Move editable document |
533
+ | `/api/docs/:id/duplicate` | POST | Duplicate editable document |
534
+ | `/api/docs/:id/refactor-plan` | POST | Preview file-op warnings |
535
+ | `/api/docs/:id/deactivate` | POST | Remove from index |
536
+ | `/api/doc` | GET | Get document content |
537
+ | `/api/doc/:id/sections` | GET | Get document sections |
538
+ | `/api/collections` | POST | Add collection |
539
+ | `/api/collections/:name` | DELETE | Remove collection |
540
+ | `/api/folders` | POST | Create folder |
541
+ | `/api/sync` | POST | Trigger re-index |
542
+ | `/api/status` | GET | Index statistics |
543
+ | `/api/note-presets` | GET | List note presets |
544
+ | `/api/presets` | GET | List model presets |
545
+ | `/api/presets` | POST | Switch preset |
546
+ | `/api/models/pull` | POST | Download models |
547
+ | `/api/models/status` | GET | Download progress |
537
548
 
538
549
  No authentication. No rate limits. Build custom tools, automate workflows, integrate with any language.
539
550
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Local semantic search for your documents. Index Markdown, PDF, and Office files with hybrid BM25 + vector search.",
5
5
  "keywords": [
6
6
  "embeddings",
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  // node:fs/promises for rename/unlink (no Bun equivalent for structure ops)
8
- import { mkdir, rename, unlink } from "node:fs/promises";
8
+ import { copyFile, mkdir, rename, unlink } from "node:fs/promises";
9
9
  // node:os platform/homedir/tmpdir: no Bun equivalent
10
10
  import { homedir, platform as getPlatform, tmpdir } from "node:os";
11
11
  // node:path dirname/join/parse: no Bun equivalent
@@ -48,6 +48,17 @@ export async function renameFilePath(
48
48
  await rename(currentPath, nextPath);
49
49
  }
50
50
 
51
+ export async function copyFilePath(
52
+ currentPath: string,
53
+ nextPath: string
54
+ ): Promise<void> {
55
+ await copyFile(currentPath, nextPath);
56
+ }
57
+
58
+ export async function createFolderPath(path: string): Promise<void> {
59
+ await mkdir(path, { recursive: true });
60
+ }
61
+
51
62
  type TrashFileDeps = {
52
63
  homeDir?: string;
53
64
  platform?: ReturnType<typeof getPlatform>;
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Shared file refactor planning helpers.
3
+ *
4
+ * Browser-safe path planning, with warning generation based on known link data.
5
+ *
6
+ * @module src/core/file-refactors
7
+ */
8
+
9
+ // node:path has no Bun equivalent
10
+ import { posix as pathPosix } from "node:path";
11
+
12
+ import { validateRelPath } from "./validation";
13
+
14
+ export interface RefactorWarningSummary {
15
+ warnings: string[];
16
+ backlinkCount: number;
17
+ wikiLinkCount: number;
18
+ markdownLinkCount: number;
19
+ }
20
+
21
+ export interface RefactorLinkSnapshot {
22
+ backlinks: number;
23
+ wikiLinks: number;
24
+ markdownLinks: number;
25
+ }
26
+
27
+ export interface RenamePlan {
28
+ nextRelPath: string;
29
+ nextUri: string;
30
+ }
31
+
32
+ export interface MovePlan {
33
+ nextRelPath: string;
34
+ nextUri: string;
35
+ }
36
+
37
+ export interface DuplicatePlan {
38
+ nextRelPath: string;
39
+ nextUri: string;
40
+ }
41
+
42
+ export function buildRefactorWarnings(
43
+ snapshot: RefactorLinkSnapshot,
44
+ options: {
45
+ filenameChanged?: boolean;
46
+ folderChanged?: boolean;
47
+ } = {}
48
+ ): RefactorWarningSummary {
49
+ const warnings: string[] = [];
50
+
51
+ if (snapshot.backlinks > 0) {
52
+ warnings.push(
53
+ `${snapshot.backlinks} backlink${snapshot.backlinks === 1 ? "" : "s"} may need review after this refactor.`
54
+ );
55
+ }
56
+ if (options.filenameChanged && snapshot.wikiLinks > 0) {
57
+ warnings.push(
58
+ `${snapshot.wikiLinks} wiki link${snapshot.wikiLinks === 1 ? "" : "s"} may depend on the current title/path identity.`
59
+ );
60
+ }
61
+ if (
62
+ (options.filenameChanged || options.folderChanged) &&
63
+ snapshot.markdownLinks > 0
64
+ ) {
65
+ warnings.push(
66
+ `${snapshot.markdownLinks} markdown link${snapshot.markdownLinks === 1 ? "" : "s"} may require path rewrite or manual review.`
67
+ );
68
+ }
69
+
70
+ return {
71
+ warnings,
72
+ backlinkCount: snapshot.backlinks,
73
+ wikiLinkCount: snapshot.wikiLinks,
74
+ markdownLinkCount: snapshot.markdownLinks,
75
+ };
76
+ }
77
+
78
+ function nextAvailableRelPath(relPath: string, existing: Set<string>): string {
79
+ const parsed = pathPosix.parse(relPath);
80
+ const dir = parsed.dir ? `${parsed.dir}/` : "";
81
+ const base = parsed.name || "copy";
82
+ const ext = parsed.ext || ".md";
83
+
84
+ let counter = 2;
85
+ while (true) {
86
+ const candidate = `${dir}${base}-${counter}${ext}`;
87
+ if (!existing.has(candidate)) {
88
+ return candidate;
89
+ }
90
+ counter += 1;
91
+ }
92
+ }
93
+
94
+ export function planRenameRefactor(input: {
95
+ collection: string;
96
+ currentRelPath: string;
97
+ nextName: string;
98
+ }): RenamePlan {
99
+ const current = validateRelPath(input.currentRelPath);
100
+ const directory = pathPosix.dirname(current);
101
+ const currentExt = pathPosix.extname(current);
102
+ const nextFilename = pathPosix.extname(input.nextName)
103
+ ? input.nextName
104
+ : `${input.nextName}${currentExt}`;
105
+ const nextRelPath =
106
+ directory === "."
107
+ ? validateRelPath(nextFilename)
108
+ : validateRelPath(`${directory}/${nextFilename}`);
109
+
110
+ return {
111
+ nextRelPath,
112
+ nextUri: `gno://${input.collection}/${nextRelPath}`,
113
+ };
114
+ }
115
+
116
+ export function planMoveRefactor(input: {
117
+ collection: string;
118
+ currentRelPath: string;
119
+ folderPath: string;
120
+ nextName?: string;
121
+ }): MovePlan {
122
+ const current = validateRelPath(input.currentRelPath);
123
+ const safeFolder = validateRelPath(input.folderPath).replace(
124
+ /^\.\/|\/+$/g,
125
+ ""
126
+ );
127
+ const filename = input.nextName?.trim() || pathPosix.basename(current);
128
+ const nextRelPath = safeFolder
129
+ ? validateRelPath(`${safeFolder}/${filename}`)
130
+ : validateRelPath(filename);
131
+
132
+ return {
133
+ nextRelPath,
134
+ nextUri: `gno://${input.collection}/${nextRelPath}`,
135
+ };
136
+ }
137
+
138
+ export function planDuplicateRefactor(input: {
139
+ collection: string;
140
+ currentRelPath: string;
141
+ folderPath?: string;
142
+ nextName?: string;
143
+ existingRelPaths: Iterable<string>;
144
+ }): DuplicatePlan {
145
+ const current = validateRelPath(input.currentRelPath);
146
+ const existing = new Set(input.existingRelPaths);
147
+ const targetFolder = input.folderPath
148
+ ? validateRelPath(input.folderPath).replace(/^\.\/|\/+$/g, "")
149
+ : pathPosix.dirname(current) === "."
150
+ ? ""
151
+ : pathPosix.dirname(current);
152
+ const baseName = input.nextName?.trim() || pathPosix.basename(current);
153
+ const initialRelPath = targetFolder
154
+ ? validateRelPath(`${targetFolder}/${baseName}`)
155
+ : validateRelPath(baseName);
156
+ const nextRelPath = existing.has(initialRelPath)
157
+ ? nextAvailableRelPath(initialRelPath, existing)
158
+ : initialRelPath;
159
+
160
+ return {
161
+ nextRelPath,
162
+ nextUri: `gno://${input.collection}/${nextRelPath}`,
163
+ };
164
+ }
165
+
166
+ export function planCreateFolder(input: {
167
+ parentPath?: string;
168
+ name: string;
169
+ }): string {
170
+ const safeName = input.name.trim().replaceAll(/[\\/]+/g, "");
171
+ if (!safeName) {
172
+ throw new Error("Folder name cannot be empty");
173
+ }
174
+ const safeParent = input.parentPath
175
+ ? validateRelPath(input.parentPath).replace(/^\.\/|\/+$/g, "")
176
+ : "";
177
+ return safeParent
178
+ ? validateRelPath(`${safeParent}/${safeName}`)
179
+ : validateRelPath(safeName);
180
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Shared note creation path resolution and collision handling.
3
+ *
4
+ * Browser-safe: no Bun APIs.
5
+ *
6
+ * @module src/core/note-creation
7
+ */
8
+
9
+ // node:path has no Bun equivalent
10
+ import { posix as pathPosix } from "node:path";
11
+
12
+ import { validateRelPath } from "./validation";
13
+
14
+ export type NoteCollisionPolicy =
15
+ | "error"
16
+ | "open_existing"
17
+ | "create_with_suffix";
18
+
19
+ export interface ResolveNoteCreateInput {
20
+ collection: string;
21
+ title?: string;
22
+ relPath?: string;
23
+ folderPath?: string;
24
+ collisionPolicy?: NoteCollisionPolicy;
25
+ }
26
+
27
+ export interface NoteCreatePlan {
28
+ collection: string;
29
+ folderPath: string;
30
+ relPath: string;
31
+ filename: string;
32
+ collisionPolicy: NoteCollisionPolicy;
33
+ openedExisting: boolean;
34
+ createdWithSuffix: boolean;
35
+ }
36
+
37
+ export function sanitizeNoteFilename(title: string): string {
38
+ return title
39
+ .normalize("NFC")
40
+ .toLowerCase()
41
+ .trim()
42
+ .replaceAll(/[^\w\s-]/g, "")
43
+ .replaceAll(/\s+/g, "-")
44
+ .replaceAll(/-+/g, "-")
45
+ .replace(/^-|-$/g, "");
46
+ }
47
+
48
+ function ensureMarkdownFilename(filename: string): string {
49
+ return pathPosix.extname(filename) ? filename : `${filename}.md`;
50
+ }
51
+
52
+ function nextAvailableRelPath(relPath: string, existing: Set<string>): string {
53
+ const parsed = pathPosix.parse(relPath);
54
+ const ext = parsed.ext || ".md";
55
+ const dir = parsed.dir ? `${parsed.dir}/` : "";
56
+ const base = parsed.name || "untitled";
57
+
58
+ let counter = 2;
59
+ while (true) {
60
+ const candidate = `${dir}${base}-${counter}${ext}`;
61
+ if (!existing.has(candidate)) {
62
+ return candidate;
63
+ }
64
+ counter += 1;
65
+ }
66
+ }
67
+
68
+ export function resolveNoteCreatePlan(
69
+ input: ResolveNoteCreateInput,
70
+ existingRelPaths: Iterable<string>
71
+ ): NoteCreatePlan {
72
+ const collisionPolicy = input.collisionPolicy ?? "error";
73
+ const existing = new Set(existingRelPaths);
74
+ const safeFolderPath = input.folderPath
75
+ ? validateRelPath(input.folderPath).replace(/^\.\/|\/+$/g, "")
76
+ : "";
77
+
78
+ let baseRelPath: string;
79
+ if (input.relPath?.trim()) {
80
+ baseRelPath = validateRelPath(input.relPath.trim());
81
+ } else {
82
+ const baseTitle = input.title?.trim() || "untitled";
83
+ const filename = ensureMarkdownFilename(
84
+ sanitizeNoteFilename(baseTitle) || "untitled"
85
+ );
86
+ baseRelPath = safeFolderPath ? `${safeFolderPath}/${filename}` : filename;
87
+ }
88
+
89
+ const relPath = safeFolderPath
90
+ ? baseRelPath.startsWith(`${safeFolderPath}/`) ||
91
+ baseRelPath === safeFolderPath
92
+ ? baseRelPath
93
+ : `${safeFolderPath}/${pathPosix.basename(baseRelPath)}`
94
+ : baseRelPath;
95
+ const normalizedRelPath = validateRelPath(relPath);
96
+
97
+ if (!existing.has(normalizedRelPath)) {
98
+ return {
99
+ collection: input.collection,
100
+ folderPath: safeFolderPath,
101
+ relPath: normalizedRelPath,
102
+ filename: pathPosix.basename(normalizedRelPath),
103
+ collisionPolicy,
104
+ openedExisting: false,
105
+ createdWithSuffix: false,
106
+ };
107
+ }
108
+
109
+ if (collisionPolicy === "open_existing") {
110
+ return {
111
+ collection: input.collection,
112
+ folderPath: safeFolderPath,
113
+ relPath: normalizedRelPath,
114
+ filename: pathPosix.basename(normalizedRelPath),
115
+ collisionPolicy,
116
+ openedExisting: true,
117
+ createdWithSuffix: false,
118
+ };
119
+ }
120
+
121
+ if (collisionPolicy === "create_with_suffix") {
122
+ const nextRelPath = nextAvailableRelPath(normalizedRelPath, existing);
123
+ return {
124
+ collection: input.collection,
125
+ folderPath: safeFolderPath,
126
+ relPath: nextRelPath,
127
+ filename: pathPosix.basename(nextRelPath),
128
+ collisionPolicy,
129
+ openedExisting: false,
130
+ createdWithSuffix: true,
131
+ };
132
+ }
133
+
134
+ throw new Error(
135
+ "File already exists. Use open_existing or create_with_suffix."
136
+ );
137
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Shared note preset definitions and scaffold helpers.
3
+ *
4
+ * Browser-safe: no Bun APIs.
5
+ *
6
+ * @module src/core/note-presets
7
+ */
8
+
9
+ import { normalizeTag } from "./tags";
10
+
11
+ export type NotePresetId =
12
+ | "blank"
13
+ | "project-note"
14
+ | "research-note"
15
+ | "decision-note"
16
+ | "prompt-pattern"
17
+ | "source-summary";
18
+
19
+ export interface NotePresetDefinition {
20
+ id: NotePresetId;
21
+ label: string;
22
+ description: string;
23
+ defaultTags?: string[];
24
+ frontmatter?: Record<string, string | string[]>;
25
+ body: (title: string) => string;
26
+ }
27
+
28
+ export interface ResolvedNotePreset {
29
+ preset: NotePresetDefinition;
30
+ title: string;
31
+ tags: string[];
32
+ frontmatter: Record<string, string | string[]>;
33
+ body: string;
34
+ content: string;
35
+ }
36
+
37
+ function serializeFrontmatterValue(value: string | string[]): string[] {
38
+ if (Array.isArray(value)) {
39
+ return value.length === 0
40
+ ? []
41
+ : value.map((entry) => ` - ${JSON.stringify(entry)}`);
42
+ }
43
+ return [JSON.stringify(value)];
44
+ }
45
+
46
+ export function serializeFrontmatter(
47
+ data: Record<string, string | string[]>
48
+ ): string {
49
+ const entries = Object.entries(data);
50
+ if (entries.length === 0) {
51
+ return "";
52
+ }
53
+
54
+ const lines = ["---"];
55
+ for (const [key, value] of entries) {
56
+ if (Array.isArray(value)) {
57
+ if (value.length === 0) {
58
+ lines.push(`${key}: []`);
59
+ continue;
60
+ }
61
+ lines.push(`${key}:`);
62
+ } else {
63
+ lines.push(`${key}: ${serializeFrontmatterValue(value)[0]}`);
64
+ continue;
65
+ }
66
+ for (const line of serializeFrontmatterValue(value)) {
67
+ lines.push(line);
68
+ }
69
+ }
70
+ lines.push("---", "");
71
+ return lines.join("\n");
72
+ }
73
+
74
+ export const NOTE_PRESETS: NotePresetDefinition[] = [
75
+ {
76
+ id: "blank",
77
+ label: "Blank",
78
+ description: "Empty note with only a title heading.",
79
+ body: (title) => `# ${title}\n`,
80
+ },
81
+ {
82
+ id: "project-note",
83
+ label: "Project Note",
84
+ description: "Goal, scope, open questions, and next steps.",
85
+ defaultTags: ["project"],
86
+ frontmatter: {
87
+ category: "project",
88
+ status: "active",
89
+ },
90
+ body: (title) =>
91
+ `# ${title}\n\n## Goal\n\n## Scope\n\n## Open Questions\n\n## Next Steps\n`,
92
+ },
93
+ {
94
+ id: "research-note",
95
+ label: "Research Note",
96
+ description: "Summary, key ideas, evidence, and follow-up questions.",
97
+ defaultTags: ["research"],
98
+ frontmatter: {
99
+ category: "research",
100
+ },
101
+ body: (title) =>
102
+ `# ${title}\n\n## Summary\n\n## Key Ideas\n\n## Evidence\n\n## Follow-up Questions\n`,
103
+ },
104
+ {
105
+ id: "decision-note",
106
+ label: "Decision Note",
107
+ description: "Context, decision, rationale, and consequences.",
108
+ defaultTags: ["decision"],
109
+ frontmatter: {
110
+ category: "decision",
111
+ status: "proposed",
112
+ },
113
+ body: (title) =>
114
+ `# ${title}\n\n## Context\n\n## Decision\n\n## Rationale\n\n## Consequences\n`,
115
+ },
116
+ {
117
+ id: "prompt-pattern",
118
+ label: "Prompt / Pattern",
119
+ description: "Capture reusable prompts, constraints, and examples.",
120
+ defaultTags: ["prompt", "pattern"],
121
+ frontmatter: {
122
+ category: "pattern",
123
+ },
124
+ body: (title) =>
125
+ `# ${title}\n\n## Use Case\n\n## Prompt\n\n## Constraints\n\n## Example\n`,
126
+ },
127
+ {
128
+ id: "source-summary",
129
+ label: "Source Summary",
130
+ description: "Summarize an article, paper, or external source cleanly.",
131
+ defaultTags: ["source", "summary"],
132
+ frontmatter: {
133
+ category: "source-summary",
134
+ sources: [],
135
+ },
136
+ body: (title) =>
137
+ `# ${title}\n\n## Summary\n\n## Important Claims\n\n## Evidence / Quotes\n\n## Takeaways\n`,
138
+ },
139
+ ];
140
+
141
+ export function getNotePreset(
142
+ presetId?: string | null
143
+ ): NotePresetDefinition | null {
144
+ if (!presetId) {
145
+ return null;
146
+ }
147
+ return NOTE_PRESETS.find((preset) => preset.id === presetId) ?? null;
148
+ }
149
+
150
+ export function resolveNotePreset(input: {
151
+ presetId?: string | null;
152
+ title: string;
153
+ tags?: string[];
154
+ frontmatter?: Record<string, string | string[]>;
155
+ body?: string;
156
+ }): ResolvedNotePreset | null {
157
+ const preset = getNotePreset(input.presetId);
158
+ if (!preset) {
159
+ return null;
160
+ }
161
+
162
+ const title = input.title.trim() || "Untitled";
163
+ const presetTags = (preset.defaultTags ?? []).map(normalizeTag);
164
+ const tags = [
165
+ ...new Set([...(input.tags ?? []).map(normalizeTag), ...presetTags]),
166
+ ];
167
+ const frontmatter = {
168
+ ...preset.frontmatter,
169
+ ...input.frontmatter,
170
+ ...(tags.length > 0 ? { tags } : {}),
171
+ };
172
+ const body = input.body?.trim().length ? input.body : preset.body(title);
173
+ const frontmatterBlock = serializeFrontmatter(frontmatter);
174
+
175
+ return {
176
+ preset,
177
+ title,
178
+ tags,
179
+ frontmatter,
180
+ body,
181
+ content: `${frontmatterBlock}${body}`.trimEnd() + "\n",
182
+ };
183
+ }