@bendyline/docblocks 1.0.0 → 1.1.1

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.
Files changed (54) hide show
  1. package/dist/{chunk-NSVTXALR.js → chunk-LORJUBON.js} +314 -25
  2. package/dist/chunk-LORJUBON.js.map +1 -0
  3. package/dist/chunk-ME76RUMR.js +23 -0
  4. package/dist/chunk-ME76RUMR.js.map +1 -0
  5. package/dist/filesystem/electron-provider.d.ts +32 -0
  6. package/dist/filesystem/electron-provider.d.ts.map +1 -0
  7. package/dist/filesystem/electron-provider.js +64 -0
  8. package/dist/filesystem/electron-provider.js.map +1 -0
  9. package/dist/filesystem/file-media-provider.d.ts +23 -0
  10. package/dist/filesystem/file-media-provider.d.ts.map +1 -0
  11. package/dist/filesystem/file-media-provider.js +85 -0
  12. package/dist/filesystem/file-media-provider.js.map +1 -0
  13. package/dist/filesystem/filesystem-content-container.d.ts +24 -0
  14. package/dist/filesystem/filesystem-content-container.d.ts.map +1 -0
  15. package/dist/filesystem/filesystem-content-container.js +116 -0
  16. package/dist/filesystem/filesystem-content-container.js.map +1 -0
  17. package/dist/filesystem/index.d.ts +3 -0
  18. package/dist/filesystem/index.d.ts.map +1 -1
  19. package/dist/filesystem/index.js +3 -0
  20. package/dist/filesystem/index.js.map +1 -1
  21. package/dist/filesystem/indexeddb-provider.d.ts.map +1 -1
  22. package/dist/filesystem/indexeddb-provider.js +60 -18
  23. package/dist/filesystem/indexeddb-provider.js.map +1 -1
  24. package/dist/filesystem/native-provider.d.ts +1 -0
  25. package/dist/filesystem/native-provider.d.ts.map +1 -1
  26. package/dist/filesystem/native-provider.js +40 -8
  27. package/dist/filesystem/native-provider.js.map +1 -1
  28. package/dist/host/index.d.ts +14 -0
  29. package/dist/host/index.d.ts.map +1 -0
  30. package/dist/host/index.js +28 -0
  31. package/dist/host/index.js.map +1 -0
  32. package/dist/host/types.d.ts +149 -0
  33. package/dist/host/types.d.ts.map +1 -0
  34. package/dist/host/types.js +10 -0
  35. package/dist/host/types.js.map +1 -0
  36. package/dist/index-Bunj8Kb_.d.ts +213 -0
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +2 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/workspace/types.d.ts +16 -2
  42. package/dist/workspace/types.d.ts.map +1 -1
  43. package/package.json +7 -7
  44. package/src/filesystem/electron-provider.ts +88 -0
  45. package/src/filesystem/file-media-provider.ts +103 -0
  46. package/src/filesystem/filesystem-content-container.ts +126 -0
  47. package/src/filesystem/index.ts +4 -0
  48. package/src/filesystem/indexeddb-provider.ts +65 -19
  49. package/src/filesystem/native-provider.ts +41 -8
  50. package/src/host/index.ts +47 -0
  51. package/src/host/types.ts +153 -0
  52. package/src/index.ts +2 -1
  53. package/src/workspace/types.ts +16 -2
  54. package/dist/chunk-NSVTXALR.js.map +0 -1
@@ -0,0 +1,88 @@
1
+ /**
2
+ * ElectronFileSystemProvider — implements FileSystemProvider by delegating
3
+ * to the Electron desktop host's fs IPC bridge. Every operation is scoped
4
+ * to an absolute root path that the main process validates against a
5
+ * whitelist of registered workspace roots.
6
+ *
7
+ * This file has no Electron dependency — it is a pure IPC client that
8
+ * relies on the `docBlocksHost` global installed by the preload script.
9
+ */
10
+
11
+ import type { FileSystemProvider, FileSystemEntry, FileMeta } from './types.js';
12
+ import { maybeGetDocBlocksHost } from '../host/index.js';
13
+ import type { DocBlocksHostFsAPI } from '../host/types.js';
14
+
15
+ export { isElectronHost } from '../host/index.js';
16
+
17
+ function getHostFs(): DocBlocksHostFsAPI {
18
+ const host = maybeGetDocBlocksHost();
19
+ if (!host) {
20
+ throw new Error(
21
+ 'ElectronFileSystemProvider: docBlocksHost is not available — not running under Electron?',
22
+ );
23
+ }
24
+ return host.fs;
25
+ }
26
+
27
+ export class ElectronFileSystemProvider implements FileSystemProvider {
28
+ readonly id: string;
29
+ readonly label: string;
30
+
31
+ private readonly rootPath: string;
32
+
33
+ constructor(id: string, label: string, rootPath: string) {
34
+ this.id = id;
35
+ this.label = label;
36
+ this.rootPath = rootPath;
37
+ }
38
+
39
+ /** Absolute path this provider is rooted at. */
40
+ getRootPath(): string {
41
+ return this.rootPath;
42
+ }
43
+
44
+ readFile(path: string): Promise<string | null> {
45
+ return getHostFs().readFile(this.rootPath, path);
46
+ }
47
+
48
+ writeFile(path: string, content: string): Promise<void> {
49
+ return getHostFs().writeFile(this.rootPath, path, content);
50
+ }
51
+
52
+ delete(path: string): Promise<void> {
53
+ return getHostFs().delete(this.rootPath, path);
54
+ }
55
+
56
+ rename(oldPath: string, newPath: string): Promise<void> {
57
+ return getHostFs().rename(this.rootPath, oldPath, newPath);
58
+ }
59
+
60
+ readDirectory(path: string): Promise<FileSystemEntry[]> {
61
+ return getHostFs().readDirectory(this.rootPath, path);
62
+ }
63
+
64
+ exists(path: string): Promise<boolean> {
65
+ return getHostFs().exists(this.rootPath, path);
66
+ }
67
+
68
+ createDirectory(path: string): Promise<void> {
69
+ return getHostFs().createDirectory(this.rootPath, path);
70
+ }
71
+
72
+ stat(path: string): Promise<FileMeta | null> {
73
+ return getHostFs().stat(this.rootPath, path);
74
+ }
75
+
76
+ readBinary(path: string): Promise<ArrayBuffer | null> {
77
+ return getHostFs().readBinary(this.rootPath, path);
78
+ }
79
+
80
+ writeBinary(path: string, data: ArrayBuffer | Uint8Array): Promise<void> {
81
+ return getHostFs().writeBinary(this.rootPath, path, data);
82
+ }
83
+
84
+ /** Subscribe to external change notifications under this root. */
85
+ watch(onChange: (changedPath: string) => void): () => void {
86
+ return getHostFs().watch(this.rootPath, onChange);
87
+ }
88
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * createFileMediaProvider — per-file media storage following the pandoc /
3
+ * Word convention: a markdown file `notes.md` gets a sibling folder
4
+ * `notes_files/` that holds its images, audio, and video.
5
+ *
6
+ * Given:
7
+ * • `container` — a ContentContainer scoped to the markdown file's
8
+ * parent directory (so `readFile('notes_files/image.png')` maps to the
9
+ * parent-relative path)
10
+ * • `markdownBasename` — e.g. `"notes.md"`
11
+ *
12
+ * Returns a MediaProvider that:
13
+ * • Writes new media under `{basename}_files/{name}` in the parent dir
14
+ * • Returns the folder-qualified path (`notes_files/image.png`) from
15
+ * addMedia so the markdown stays portable outside DocBlocks
16
+ * • Resolves both bare (`image.png`) and folder-qualified
17
+ * (`notes_files/image.png`) references — so legacy markdown and
18
+ * exports from other tools both work
19
+ */
20
+
21
+ import type { MediaProvider, MediaEntry } from '@bendyline/squisq/schemas';
22
+ import type { ContentContainer } from '@bendyline/squisq/storage';
23
+
24
+ function stripExt(name: string): string {
25
+ return name.replace(/\.[^.]+$/, '');
26
+ }
27
+
28
+ export function createFileMediaProvider(
29
+ container: ContentContainer,
30
+ markdownBasename: string,
31
+ ): MediaProvider {
32
+ const folder = stripExt(markdownBasename) + '_files';
33
+ const prefix = folder + '/';
34
+ const blobUrlCache = new Map<string, string>();
35
+
36
+ function toKey(ref: string): string {
37
+ const clean = ref.replace(/^\/+/, '');
38
+ return clean.startsWith(prefix) ? clean : prefix + clean;
39
+ }
40
+
41
+ return {
42
+ async resolveUrl(ref: string): Promise<string> {
43
+ const key = toKey(ref);
44
+ const cached = blobUrlCache.get(key);
45
+ if (cached) return cached;
46
+
47
+ const data = await container.readFile(key);
48
+ if (!data) return ref;
49
+
50
+ const entries = await container.listFiles();
51
+ const entry = entries.find((e) => e.path === key);
52
+ const mimeType = entry?.mimeType ?? 'application/octet-stream';
53
+
54
+ const url = URL.createObjectURL(new Blob([data], { type: mimeType }));
55
+ blobUrlCache.set(key, url);
56
+ return url;
57
+ },
58
+
59
+ async listMedia(): Promise<MediaEntry[]> {
60
+ const entries = await container.listFiles(prefix);
61
+ return entries
62
+ .filter((e) => !e.path.toLowerCase().endsWith('.md'))
63
+ .map((e) => ({
64
+ name: e.path,
65
+ mimeType: e.mimeType,
66
+ size: e.size,
67
+ }));
68
+ },
69
+
70
+ async addMedia(
71
+ name: string,
72
+ data: ArrayBuffer | Blob | Uint8Array,
73
+ mimeType: string,
74
+ ): Promise<string> {
75
+ const key = toKey(name);
76
+ const cached = blobUrlCache.get(key);
77
+ if (cached) {
78
+ URL.revokeObjectURL(cached);
79
+ blobUrlCache.delete(key);
80
+ }
81
+ const buffer = data instanceof Blob ? new Uint8Array(await data.arrayBuffer()) : data;
82
+ await container.writeFile(key, buffer, mimeType);
83
+ return key;
84
+ },
85
+
86
+ async removeMedia(ref: string): Promise<void> {
87
+ const key = toKey(ref);
88
+ const cached = blobUrlCache.get(key);
89
+ if (cached) {
90
+ URL.revokeObjectURL(cached);
91
+ blobUrlCache.delete(key);
92
+ }
93
+ await container.removeFile(key);
94
+ },
95
+
96
+ dispose(): void {
97
+ for (const url of blobUrlCache.values()) {
98
+ URL.revokeObjectURL(url);
99
+ }
100
+ blobUrlCache.clear();
101
+ },
102
+ };
103
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * FileSystemContentContainer — a ContentContainer backed by any
3
+ * FileSystemProvider, scoped to a sub-path (e.g., ".docblocks/media/").
4
+ *
5
+ * Used by the Electron desktop app so media lives inside the workspace
6
+ * folder (visible as regular files) rather than in a separate IndexedDB
7
+ * origin that only the app can see.
8
+ */
9
+
10
+ import type { ContentContainer, ContentEntry } from '@bendyline/squisq/storage';
11
+ import { findDocumentPath } from '@bendyline/squisq/storage';
12
+ import type { FileSystemProvider } from './types.js';
13
+
14
+ const EXTENSION_MIME_MAP: Record<string, string> = {
15
+ '.md': 'text/markdown',
16
+ '.txt': 'text/plain',
17
+ '.json': 'application/json',
18
+ '.jpg': 'image/jpeg',
19
+ '.jpeg': 'image/jpeg',
20
+ '.png': 'image/png',
21
+ '.gif': 'image/gif',
22
+ '.svg': 'image/svg+xml',
23
+ '.webp': 'image/webp',
24
+ '.avif': 'image/avif',
25
+ '.mp4': 'video/mp4',
26
+ '.webm': 'video/webm',
27
+ '.mp3': 'audio/mpeg',
28
+ '.wav': 'audio/wav',
29
+ '.ogg': 'audio/ogg',
30
+ };
31
+
32
+ function guessMimeType(path: string): string {
33
+ const dot = path.lastIndexOf('.');
34
+ if (dot === -1) return 'application/octet-stream';
35
+ const ext = path.slice(dot).toLowerCase();
36
+ return EXTENSION_MIME_MAP[ext] ?? 'application/octet-stream';
37
+ }
38
+
39
+ function joinPrefix(prefix: string, p: string): string {
40
+ const clean = p.replace(/^\/+/, '');
41
+ return prefix.replace(/\/+$/, '') + '/' + clean;
42
+ }
43
+
44
+ export class FileSystemContentContainer implements ContentContainer {
45
+ private readonly prefix: string;
46
+
47
+ constructor(
48
+ private readonly provider: FileSystemProvider,
49
+ prefix = '.docblocks/media',
50
+ ) {
51
+ this.prefix = prefix.replace(/^\/+/, '').replace(/\/+$/, '');
52
+ }
53
+
54
+ async readFile(path: string): Promise<ArrayBuffer | null> {
55
+ const full = joinPrefix(this.prefix, path);
56
+ const binary = await this.provider.readBinary(full);
57
+ if (binary) return binary;
58
+ // IndexedDB-backed workspaces store text via `writeFile(string)` and
59
+ // binary via `writeBinary(ArrayBuffer)` under separate keys, so a
60
+ // markdown file written through the text API is invisible to
61
+ // `readBinary`. Fall back to text-then-UTF-8 so consumers like the
62
+ // recursive HTML export can resolve sibling `.md` links uniformly
63
+ // across browser and native workspaces.
64
+ const text = await this.provider.readFile(full);
65
+ if (text === null) return null;
66
+ return new TextEncoder().encode(text).buffer as ArrayBuffer;
67
+ }
68
+
69
+ async writeFile(path: string, data: ArrayBuffer | Uint8Array, _mimeType?: string): Promise<void> {
70
+ await this.provider.writeBinary(joinPrefix(this.prefix, path), data);
71
+ }
72
+
73
+ async removeFile(path: string): Promise<void> {
74
+ await this.provider.delete(joinPrefix(this.prefix, path));
75
+ }
76
+
77
+ async listFiles(prefix?: string): Promise<ContentEntry[]> {
78
+ const entries: ContentEntry[] = [];
79
+ const walk = async (dir: string) => {
80
+ let children;
81
+ try {
82
+ children = await this.provider.readDirectory(dir);
83
+ } catch {
84
+ return;
85
+ }
86
+ for (const child of children) {
87
+ if (child.kind === 'directory') {
88
+ await walk(child.path);
89
+ } else {
90
+ const rel = child.path.replace(new RegExp('^/?' + this.prefix + '/?'), '');
91
+ if (prefix && !rel.startsWith(prefix)) continue;
92
+ const meta = await this.provider.stat(child.path);
93
+ entries.push({
94
+ path: rel,
95
+ mimeType: guessMimeType(rel),
96
+ size: meta?.size ?? 0,
97
+ });
98
+ }
99
+ }
100
+ };
101
+ await walk('/' + this.prefix);
102
+ return entries;
103
+ }
104
+
105
+ async exists(path: string): Promise<boolean> {
106
+ return this.provider.exists(joinPrefix(this.prefix, path));
107
+ }
108
+
109
+ async getDocumentPath(): Promise<string | null> {
110
+ return findDocumentPath(await this.listFiles());
111
+ }
112
+
113
+ async readDocument(): Promise<string | null> {
114
+ const docPath = await this.getDocumentPath();
115
+ if (!docPath) return null;
116
+ const data = await this.readFile(docPath);
117
+ if (!data) return null;
118
+ return new TextDecoder().decode(data);
119
+ }
120
+
121
+ async writeDocument(markdown: string, filename?: string): Promise<void> {
122
+ const name = filename ?? 'index.md';
123
+ const data = new TextEncoder().encode(markdown);
124
+ await this.writeFile(name, data, 'text/markdown');
125
+ }
126
+ }
@@ -8,6 +8,8 @@ export type {
8
8
 
9
9
  export { IndexedDBFileSystemProvider } from './indexeddb-provider.js';
10
10
  export { IndexedDBContentContainer } from './indexeddb-content-container.js';
11
+ export { FileSystemContentContainer } from './filesystem-content-container.js';
12
+ export { createFileMediaProvider } from './file-media-provider.js';
11
13
 
12
14
  export {
13
15
  NativeFileSystemProvider,
@@ -18,3 +20,5 @@ export {
18
20
  loadDirectoryHandle,
19
21
  removeDirectoryHandle,
20
22
  } from './native-provider.js';
23
+
24
+ export { ElectronFileSystemProvider, isElectronHost } from './electron-provider.js';
@@ -153,6 +153,66 @@ export class IndexedDBFileSystemProvider implements FileSystemProvider {
153
153
  async rename(oldPath: string, newPath: string): Promise<void> {
154
154
  const op = normalisePath(oldPath);
155
155
  const np = normalisePath(newPath);
156
+ if (op === np) return;
157
+ if (!op || !np) {
158
+ throw new Error('Cannot rename the filesystem root');
159
+ }
160
+ if (np.startsWith(op + '/')) {
161
+ throw new Error('Cannot move a directory into itself');
162
+ }
163
+
164
+ const dirs = await this.getDirs();
165
+
166
+ // Handle directory rename by moving every tracked child directory and
167
+ // every file record keyed under the old directory prefix.
168
+ if (dirs.has(op)) {
169
+ const newParent = parentDir(np);
170
+ if (newParent) {
171
+ await this.ensureDir(newParent);
172
+ }
173
+
174
+ const allKeys = await this.store.keys();
175
+ const oldKeyPrefix = `fs:${op}/`;
176
+ const newKeyPrefix = `fs:${np}/`;
177
+ const metaSuffix = ':meta';
178
+ const keysToMove = allKeys.filter((k) => k.startsWith(oldKeyPrefix));
179
+
180
+ await Promise.all(
181
+ keysToMove.map(async (oldKey) => {
182
+ const newKey = newKeyPrefix + oldKey.slice(oldKeyPrefix.length);
183
+ if (oldKey.endsWith(metaSuffix)) {
184
+ const meta = await this.store.get<FileMeta>(oldKey);
185
+ if (meta) {
186
+ const newFilePath = newKey.slice(3, -metaSuffix.length);
187
+ await this.store.set(metaKey(newFilePath), {
188
+ ...meta,
189
+ name: baseName(newFilePath),
190
+ path: newFilePath,
191
+ });
192
+ }
193
+ } else {
194
+ const value = await this.store.get<unknown>(oldKey);
195
+ if (value !== null) {
196
+ await this.store.set(newKey, value);
197
+ }
198
+ }
199
+ await this.store.remove(oldKey);
200
+ }),
201
+ );
202
+
203
+ dirs.delete(op);
204
+ dirs.add(np);
205
+ const oldPrefix = op + '/';
206
+ const newPrefix = np + '/';
207
+ for (const d of [...dirs]) {
208
+ if (d.startsWith(oldPrefix)) {
209
+ dirs.delete(d);
210
+ dirs.add(newPrefix + d.slice(oldPrefix.length));
211
+ }
212
+ }
213
+ await this.saveDirs(dirs);
214
+ return;
215
+ }
156
216
 
157
217
  // Read existing data
158
218
  const content = await this.store.get<string>(contentKey(op));
@@ -167,9 +227,11 @@ export class IndexedDBFileSystemProvider implements FileSystemProvider {
167
227
  await this.store.set(binaryKey(np), binary);
168
228
  }
169
229
  if (meta) {
170
- meta.name = baseName(np);
171
- meta.path = np;
172
- await this.store.set(metaKey(np), meta);
230
+ await this.store.set(metaKey(np), {
231
+ ...meta,
232
+ name: baseName(np),
233
+ path: np,
234
+ });
173
235
  }
174
236
 
175
237
  // Ensure parent dir of new path exists
@@ -182,22 +244,6 @@ export class IndexedDBFileSystemProvider implements FileSystemProvider {
182
244
  await this.store.remove(contentKey(op));
183
245
  await this.store.remove(binaryKey(op));
184
246
  await this.store.remove(metaKey(op));
185
-
186
- // Handle directory rename
187
- const dirs = await this.getDirs();
188
- if (dirs.has(op)) {
189
- dirs.delete(op);
190
- dirs.add(np);
191
- const oldPrefix = op + '/';
192
- const newPrefix = np + '/';
193
- for (const d of [...dirs]) {
194
- if (d.startsWith(oldPrefix)) {
195
- dirs.delete(d);
196
- dirs.add(newPrefix + d.slice(oldPrefix.length));
197
- }
198
- }
199
- await this.saveDirs(dirs);
200
- }
201
247
  }
202
248
 
203
249
  async readDirectory(path: string): Promise<FileSystemEntry[]> {
@@ -153,6 +153,28 @@ export class NativeFileSystemProvider implements FileSystemProvider {
153
153
  this.root = root;
154
154
  }
155
155
 
156
+ private async copyDirectory(oldDirPath: string, newDirPath: string): Promise<boolean> {
157
+ const source = await resolveDir(this.root, oldDirPath);
158
+ if (!source) return false;
159
+ await resolveDirCreate(this.root, newDirPath);
160
+
161
+ for await (const [name, handle] of source as unknown as AsyncIterable<
162
+ [string, FileSystemHandle]
163
+ >) {
164
+ const oldChild = oldDirPath ? `${oldDirPath}/${name}` : name;
165
+ const newChild = newDirPath ? `${newDirPath}/${name}` : name;
166
+ if (handle.kind === 'directory') {
167
+ await this.copyDirectory(oldChild, newChild);
168
+ } else {
169
+ const fileHandle = await source.getFileHandle(name);
170
+ const file = await fileHandle.getFile();
171
+ await this.writeBinary(newChild, await file.arrayBuffer());
172
+ }
173
+ }
174
+
175
+ return true;
176
+ }
177
+
156
178
  async readFile(path: string): Promise<string | null> {
157
179
  const p = normalisePath(path);
158
180
  const dir = await resolveDir(this.root, parentDir(p));
@@ -187,20 +209,31 @@ export class NativeFileSystemProvider implements FileSystemProvider {
187
209
  async rename(oldPath: string, newPath: string): Promise<void> {
188
210
  const op = normalisePath(oldPath);
189
211
  const np = normalisePath(newPath);
212
+ if (op === np) return;
213
+ if (!op || !np) {
214
+ throw new Error('Cannot rename the filesystem root');
215
+ }
216
+ if (np.startsWith(op + '/')) {
217
+ throw new Error('Cannot move a directory into itself');
218
+ }
190
219
 
191
220
  // The File System Access API doesn't have a native rename.
192
- // Readwrite → delete.
193
- const content = await this.readFile(op);
194
- if (content !== null) {
195
- await this.writeFile(np, content);
221
+ // Copy → delete.
222
+ const oldParent = await resolveDir(this.root, parentDir(op));
223
+ if (!oldParent) return;
224
+
225
+ try {
226
+ const fileHandle = await oldParent.getFileHandle(baseName(op));
227
+ const file = await fileHandle.getFile();
228
+ await this.writeBinary(np, await file.arrayBuffer());
196
229
  await this.delete(op);
197
230
  return;
231
+ } catch {
232
+ // Not a file; try directory below.
198
233
  }
199
234
 
200
- // Try binary
201
- const binary = await this.readBinary(op);
202
- if (binary !== null) {
203
- await this.writeBinary(np, binary);
235
+ const copied = await this.copyDirectory(op, np);
236
+ if (copied) {
204
237
  await this.delete(op);
205
238
  }
206
239
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Host bridge — shared types + runtime access for the Electron desktop
3
+ * host. The renderer calls `getDocBlocksHost()` to reach the preload
4
+ * contextBridge; `isElectronHost()` gates desktop-only UI branches.
5
+ */
6
+
7
+ export type {
8
+ DocBlocksHostAPI,
9
+ DocBlocksHostFsAPI,
10
+ DocBlocksHostWorkspacesAPI,
11
+ DocBlocksHostShellAPI,
12
+ DocBlocksHostFfmpegAPI,
13
+ DocBlocksHostUpdaterAPI,
14
+ ElectronWorkspaceInfo,
15
+ HostEnvironment,
16
+ MenuCommand,
17
+ OpenRequest,
18
+ UpdaterStatus,
19
+ } from './types.js';
20
+
21
+ import type { DocBlocksHostAPI } from './types.js';
22
+
23
+ /** True when running inside the Electron desktop shell. */
24
+ export function isElectronHost(): boolean {
25
+ if (typeof globalThis === 'undefined') return false;
26
+ const host = (globalThis as { docBlocksHost?: unknown }).docBlocksHost;
27
+ return (
28
+ typeof host === 'object' &&
29
+ host !== null &&
30
+ typeof (host as { fs?: unknown }).fs === 'object' &&
31
+ (host as { fs?: unknown }).fs !== null
32
+ );
33
+ }
34
+
35
+ /** Return the host API, or throw if not running under Electron. */
36
+ export function getDocBlocksHost(): DocBlocksHostAPI {
37
+ const host = (globalThis as { docBlocksHost?: DocBlocksHostAPI }).docBlocksHost;
38
+ if (!host) {
39
+ throw new Error('docBlocksHost is not available — not running under Electron?');
40
+ }
41
+ return host;
42
+ }
43
+
44
+ /** Return the host API, or null if not running under Electron. */
45
+ export function maybeGetDocBlocksHost(): DocBlocksHostAPI | null {
46
+ return (globalThis as { docBlocksHost?: DocBlocksHostAPI }).docBlocksHost ?? null;
47
+ }