@gmickel/gno 0.41.1 → 0.42.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.
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Core publish export service used by CLI and local web UI.
3
+ *
4
+ * @module src/publish/export-service
5
+ */
6
+
7
+ import type { Collection } from "../config/types";
8
+ import type { DocumentRow, StorePort, TagRow } from "../store/types";
9
+
10
+ import { parseRef } from "../cli/commands/ref-parser";
11
+ import { parseFrontmatter } from "../ingestion/frontmatter";
12
+ import {
13
+ buildPublishArtifact,
14
+ buildExportedMetadata,
15
+ derivePublishSlug,
16
+ deriveExportedSlug,
17
+ deriveExportedSummary,
18
+ deriveExportedTitle,
19
+ isPublishVisibility,
20
+ type PublishArtifact,
21
+ type PublishArtifactNote,
22
+ type PublishVisibility,
23
+ } from "./artifact";
24
+
25
+ export interface PublishExportCoreOptions {
26
+ routeSlug?: string;
27
+ summary?: string;
28
+ title?: string;
29
+ visibility?: PublishVisibility;
30
+ }
31
+
32
+ function resolveVisibility(visibility?: string): PublishVisibility {
33
+ if (visibility === undefined) {
34
+ return "public";
35
+ }
36
+ if (!isPublishVisibility(visibility)) {
37
+ throw new Error(
38
+ `Invalid visibility: ${visibility}. Must be public, secret-link, invite-only, or encrypted.`
39
+ );
40
+ }
41
+ return visibility;
42
+ }
43
+
44
+ async function lookupDocument(
45
+ store: StorePort,
46
+ ref: string
47
+ ): Promise<DocumentRow | null> {
48
+ const parsed = parseRef(ref);
49
+ if ("error" in parsed) {
50
+ throw new Error(parsed.error);
51
+ }
52
+
53
+ switch (parsed.type) {
54
+ case "docid": {
55
+ const result = await store.getDocumentByDocid(parsed.value);
56
+ if (!result.ok) {
57
+ throw new Error(result.error.message);
58
+ }
59
+ return result.value;
60
+ }
61
+ case "uri": {
62
+ const result = await store.getDocumentByUri(parsed.value);
63
+ if (!result.ok) {
64
+ throw new Error(result.error.message);
65
+ }
66
+ return result.value;
67
+ }
68
+ case "collPath": {
69
+ if (!(parsed.collection && parsed.relPath)) {
70
+ return null;
71
+ }
72
+ const result = await store.getDocument(parsed.collection, parsed.relPath);
73
+ if (!result.ok) {
74
+ throw new Error(result.error.message);
75
+ }
76
+ return result.value;
77
+ }
78
+ }
79
+ }
80
+
81
+ async function loadDocumentMarkdown(
82
+ store: StorePort,
83
+ doc: DocumentRow
84
+ ): Promise<string> {
85
+ if (!doc.mirrorHash) {
86
+ throw new Error(`Document has no converted content: ${doc.uri}`);
87
+ }
88
+
89
+ const result = await store.getContent(doc.mirrorHash);
90
+ if (!result.ok || !result.value) {
91
+ throw new Error(`Unable to load content for ${doc.uri}`);
92
+ }
93
+
94
+ return result.value;
95
+ }
96
+
97
+ async function loadDocumentTags(
98
+ store: StorePort,
99
+ doc: DocumentRow
100
+ ): Promise<TagRow[]> {
101
+ const result = await store.getTagsForDoc(doc.id);
102
+ if (!result.ok) {
103
+ throw new Error(
104
+ `Unable to load tags for ${doc.uri}: ${result.error.message}`
105
+ );
106
+ }
107
+
108
+ return result.value;
109
+ }
110
+
111
+ function chooseHomeNoteSlug(notes: PublishArtifactNote[]) {
112
+ const preferred = notes.find((note) =>
113
+ ["home", "index", "readme"].includes(note.slug)
114
+ );
115
+ return preferred?.slug ?? notes[0]?.slug;
116
+ }
117
+
118
+ function resolveCollection(
119
+ collections: Collection[],
120
+ target: string
121
+ ): Collection | null {
122
+ return collections.find((collection) => collection.name === target) ?? null;
123
+ }
124
+
125
+ async function exportCollectionArtifact(
126
+ store: StorePort,
127
+ collections: Collection[],
128
+ target: string,
129
+ options: PublishExportCoreOptions
130
+ ) {
131
+ const collection = resolveCollection(collections, target);
132
+ if (!collection) {
133
+ return null;
134
+ }
135
+
136
+ const docsResult = await store.listDocuments(collection.name);
137
+ if (!docsResult.ok) {
138
+ throw new Error(docsResult.error.message);
139
+ }
140
+
141
+ const activeDocs = docsResult.value
142
+ .filter((doc) => doc.active)
143
+ .sort((left, right) => left.uri.localeCompare(right.uri));
144
+
145
+ if (activeDocs.length === 0) {
146
+ throw new Error(`Collection "${collection.name}" has no active documents`);
147
+ }
148
+
149
+ const notes: PublishArtifactNote[] = [];
150
+ for (const doc of activeDocs) {
151
+ const markdown = await loadDocumentMarkdown(store, doc);
152
+ const tags = await loadDocumentTags(store, doc);
153
+ const frontmatter = parseFrontmatter(markdown).metadata;
154
+ const title = deriveExportedTitle(doc);
155
+ notes.push({
156
+ markdown,
157
+ metadata: buildExportedMetadata(doc, frontmatter, tags),
158
+ slug: deriveExportedSlug(doc),
159
+ summary: deriveExportedSummary(markdown, frontmatter),
160
+ title,
161
+ });
162
+ }
163
+
164
+ const title = options.title ?? collection.name;
165
+ const summary =
166
+ options.summary ??
167
+ `Published snapshot of the ${collection.name} collection from local GNO.`;
168
+ const routeSlug = derivePublishSlug([
169
+ options.routeSlug ?? "",
170
+ collection.name,
171
+ target,
172
+ ]);
173
+
174
+ return buildPublishArtifact({
175
+ homeNoteSlug: chooseHomeNoteSlug(notes),
176
+ notes,
177
+ routeSlug,
178
+ source: collection.name,
179
+ sourceType: "collection",
180
+ summary,
181
+ title,
182
+ visibility: resolveVisibility(options.visibility),
183
+ });
184
+ }
185
+
186
+ async function exportDocumentArtifact(
187
+ store: StorePort,
188
+ target: string,
189
+ options: PublishExportCoreOptions
190
+ ) {
191
+ const doc = await lookupDocument(store, target);
192
+ if (!doc?.active) {
193
+ throw new Error(`Document not found: ${target}`);
194
+ }
195
+
196
+ const markdown = await loadDocumentMarkdown(store, doc);
197
+ const tags = await loadDocumentTags(store, doc);
198
+ const frontmatter = parseFrontmatter(markdown).metadata;
199
+ const title = options.title ?? deriveExportedTitle(doc);
200
+ const summary =
201
+ options.summary ?? deriveExportedSummary(markdown, frontmatter);
202
+ const slug = deriveExportedSlug(doc);
203
+
204
+ return buildPublishArtifact({
205
+ notes: [
206
+ {
207
+ markdown,
208
+ metadata: buildExportedMetadata(doc, frontmatter, tags),
209
+ slug,
210
+ summary,
211
+ title,
212
+ },
213
+ ],
214
+ routeSlug: derivePublishSlug([options.routeSlug ?? "", slug, target]),
215
+ source: doc.uri,
216
+ sourceType: "note",
217
+ summary,
218
+ title,
219
+ visibility: resolveVisibility(options.visibility),
220
+ });
221
+ }
222
+
223
+ export async function exportPublishArtifact(input: {
224
+ collections: Collection[];
225
+ options: PublishExportCoreOptions;
226
+ store: StorePort;
227
+ target: string;
228
+ }): Promise<PublishArtifact> {
229
+ return (
230
+ (await exportCollectionArtifact(
231
+ input.store,
232
+ input.collections,
233
+ input.target,
234
+ input.options
235
+ )) ??
236
+ (await exportDocumentArtifact(input.store, input.target, input.options))
237
+ );
238
+ }
@@ -65,22 +65,23 @@ Answer generation uses shared module to stay in sync with CLI:
65
65
 
66
66
  ## API Endpoints
67
67
 
68
- | Endpoint | Method | Description |
69
- | -------------------- | ------ | ------------------------------------------ |
70
- | `/api/health` | GET | Health check |
71
- | `/api/status` | GET | Index stats, onboarding, health, bootstrap |
72
- | `/api/capabilities` | GET | Available features |
73
- | `/api/collections` | GET | List collections |
74
- | `/api/docs` | GET | List documents |
75
- | `/api/doc` | GET | Get document content |
76
- | `/api/search` | POST | BM25 search |
77
- | `/api/query` | POST | Hybrid search |
78
- | `/api/ask` | POST | AI answer with citations |
79
- | `/api/presets` | GET | List model presets |
80
- | `/api/presets` | POST | Switch preset (hot-reload) |
81
- | `/api/models/status` | GET | Download progress |
82
- | `/api/models/pull` | POST | Start model download |
83
- | `/api/tags` | GET | List tags (with counts) |
68
+ | Endpoint | Method | Description |
69
+ | --------------------- | ------ | ------------------------------------------ |
70
+ | `/api/health` | GET | Health check |
71
+ | `/api/status` | GET | Index stats, onboarding, health, bootstrap |
72
+ | `/api/capabilities` | GET | Available features |
73
+ | `/api/collections` | GET | List collections |
74
+ | `/api/publish/export` | POST | Export gno.sh publish artifact JSON |
75
+ | `/api/docs` | GET | List documents |
76
+ | `/api/doc` | GET | Get document content |
77
+ | `/api/search` | POST | BM25 search |
78
+ | `/api/query` | POST | Hybrid search |
79
+ | `/api/ask` | POST | AI answer with citations |
80
+ | `/api/presets` | GET | List model presets |
81
+ | `/api/presets` | POST | Switch preset (hot-reload) |
82
+ | `/api/models/status` | GET | Download progress |
83
+ | `/api/models/pull` | POST | Start model download |
84
+ | `/api/tags` | GET | List tags (with counts) |
84
85
 
85
86
  ## Frontend
86
87
 
@@ -67,22 +67,23 @@ Answer generation uses shared module to stay in sync with CLI:
67
67
 
68
68
  ## API Endpoints
69
69
 
70
- | Endpoint | Method | Description |
71
- | -------------------- | ------ | ------------------------------------------ |
72
- | `/api/health` | GET | Health check |
73
- | `/api/status` | GET | Index stats, onboarding, health, bootstrap |
74
- | `/api/capabilities` | GET | Available features |
75
- | `/api/collections` | GET | List collections |
76
- | `/api/docs` | GET | List documents |
77
- | `/api/doc` | GET | Get document content |
78
- | `/api/search` | POST | BM25 search |
79
- | `/api/query` | POST | Hybrid search |
80
- | `/api/ask` | POST | AI answer with citations |
81
- | `/api/presets` | GET | List model presets |
82
- | `/api/presets` | POST | Switch preset (hot-reload) |
83
- | `/api/models/status` | GET | Download progress |
84
- | `/api/models/pull` | POST | Start model download |
85
- | `/api/tags` | GET | List tags (with counts) |
70
+ | Endpoint | Method | Description |
71
+ | --------------------- | ------ | ------------------------------------------ |
72
+ | `/api/health` | GET | Health check |
73
+ | `/api/status` | GET | Index stats, onboarding, health, bootstrap |
74
+ | `/api/capabilities` | GET | Available features |
75
+ | `/api/collections` | GET | List collections |
76
+ | `/api/publish/export` | POST | Export gno.sh publish artifact JSON |
77
+ | `/api/docs` | GET | List documents |
78
+ | `/api/doc` | GET | Get document content |
79
+ | `/api/search` | POST | BM25 search |
80
+ | `/api/query` | POST | Hybrid search |
81
+ | `/api/ask` | POST | AI answer with citations |
82
+ | `/api/presets` | GET | List model presets |
83
+ | `/api/presets` | POST | Switch preset (hot-reload) |
84
+ | `/api/models/status` | GET | Download progress |
85
+ | `/api/models/pull` | POST | Start model download |
86
+ | `/api/tags` | GET | List tags (with counts) |
86
87
 
87
88
  ## Frontend
88
89
 
@@ -0,0 +1,21 @@
1
+ import type { PublishArtifact } from "../../../publish/artifact";
2
+
3
+ export interface PublishExportResponse {
4
+ artifact: PublishArtifact;
5
+ fileName: string;
6
+ uploadUrl: string;
7
+ }
8
+
9
+ export function downloadPublishArtifactFile(
10
+ input: PublishExportResponse
11
+ ): void {
12
+ const blob = new Blob([JSON.stringify(input.artifact, null, 2)], {
13
+ type: "application/json",
14
+ });
15
+ const href = URL.createObjectURL(blob);
16
+ const anchor = document.createElement("a");
17
+ anchor.href = href;
18
+ anchor.download = input.fileName;
19
+ anchor.click();
20
+ URL.revokeObjectURL(href);
21
+ }
@@ -23,6 +23,7 @@ import {
23
23
  Loader2Icon,
24
24
  MoreVerticalIcon,
25
25
  RefreshCwIcon,
26
+ Share2Icon,
26
27
  } from "lucide-react";
27
28
  import { useCallback, useEffect, useState } from "react";
28
29
 
@@ -66,6 +67,10 @@ import {
66
67
  TooltipTrigger,
67
68
  } from "../components/ui/tooltip";
68
69
  import { apiFetch } from "../hooks/use-api";
70
+ import {
71
+ downloadPublishArtifactFile,
72
+ type PublishExportResponse,
73
+ } from "../lib/publish-export";
69
74
 
70
75
  interface PageProps {
71
76
  navigate: (to: string | number) => void;
@@ -119,9 +124,11 @@ interface CollectionCardProps {
119
124
  collection: CollectionStats;
120
125
  onBrowse: () => void;
121
126
  onEmbeddingCleanup: () => void;
127
+ onExport: () => void;
122
128
  onModelSettings: () => void;
123
129
  onReindex: () => void;
124
130
  onRemove: () => void;
131
+ isExporting: boolean;
125
132
  isReindexing: boolean;
126
133
  }
127
134
 
@@ -149,9 +156,11 @@ function CollectionCard({
149
156
  collection,
150
157
  onBrowse,
151
158
  onEmbeddingCleanup,
159
+ onExport,
152
160
  onModelSettings,
153
161
  onReindex,
154
162
  onRemove,
163
+ isExporting,
155
164
  isReindexing,
156
165
  }: CollectionCardProps) {
157
166
  const embedPercent =
@@ -216,6 +225,20 @@ function CollectionCard({
216
225
  <DatabaseIcon className="mr-2 size-4" />
217
226
  Embedding cleanup
218
227
  </DropdownMenuItem>
228
+ <DropdownMenuItem
229
+ disabled={actionsDisabled || isExporting}
230
+ onClick={(event) => {
231
+ event.stopPropagation();
232
+ onExport();
233
+ }}
234
+ >
235
+ {isExporting ? (
236
+ <Loader2Icon className="mr-2 size-4 animate-spin" />
237
+ ) : (
238
+ <Share2Icon className="mr-2 size-4" />
239
+ )}
240
+ Export for gno.sh
241
+ </DropdownMenuItem>
219
242
  <DropdownMenuSeparator />
220
243
  <DropdownMenuItem
221
244
  disabled={actionsDisabled || isReindexing}
@@ -337,6 +360,10 @@ export default function Collections({ navigate }: PageProps) {
337
360
  const [syncJobId, setSyncJobId] = useState<string | null>(null);
338
361
  const [syncTarget, setSyncTarget] = useState<SyncTarget>(null);
339
362
  const [syncError, setSyncError] = useState<string | null>(null);
363
+ const [exportError, setExportError] = useState<string | null>(null);
364
+ const [exportingCollectionName, setExportingCollectionName] = useState<
365
+ string | null
366
+ >(null);
340
367
  const [removeDialog, setRemoveDialog] = useState<CollectionStats | null>(
341
368
  null
342
369
  );
@@ -470,6 +497,30 @@ export default function Collections({ navigate }: PageProps) {
470
497
  await loadCollections();
471
498
  };
472
499
 
500
+ const handleExport = async (name: string) => {
501
+ setExportError(null);
502
+ setExportingCollectionName(name);
503
+
504
+ const { data, error: err } = await apiFetch<PublishExportResponse>(
505
+ "/api/publish/export",
506
+ {
507
+ body: JSON.stringify({ target: name }),
508
+ method: "POST",
509
+ }
510
+ );
511
+
512
+ setExportingCollectionName(null);
513
+
514
+ if (err) {
515
+ setExportError(err);
516
+ return;
517
+ }
518
+
519
+ if (data) {
520
+ downloadPublishArtifactFile(data);
521
+ }
522
+ };
523
+
473
524
  // Loading state
474
525
  if (loading) {
475
526
  return (
@@ -590,6 +641,14 @@ export default function Collections({ navigate }: PageProps) {
590
641
  </Card>
591
642
  )}
592
643
 
644
+ {exportError && (
645
+ <Card className="mx-auto mb-6 max-w-3xl border-destructive bg-destructive/10">
646
+ <CardContent className="py-4">
647
+ <p className="text-destructive text-sm">{exportError}</p>
648
+ </CardContent>
649
+ </Card>
650
+ )}
651
+
593
652
  {/* Error */}
594
653
  {error && (
595
654
  <Card className="mx-auto mb-6 max-w-md border-destructive bg-destructive/10">
@@ -626,6 +685,7 @@ export default function Collections({ navigate }: PageProps) {
626
685
  <CollectionCard
627
686
  actionsDisabled={Boolean(syncJobId)}
628
687
  collection={collection}
688
+ isExporting={exportingCollectionName === collection.name}
629
689
  isReindexing={
630
690
  Boolean(syncJobId) &&
631
691
  syncTarget?.kind === "collection" &&
@@ -641,6 +701,9 @@ export default function Collections({ navigate }: PageProps) {
641
701
  setEmbeddingCleanupDialog(collection);
642
702
  setEmbeddingCleanupNote(null);
643
703
  }}
704
+ onExport={() => {
705
+ void handleExport(collection.name);
706
+ }}
644
707
  onModelSettings={() => setModelDialogCollection(collection)}
645
708
  onReindex={() => void handleReindex(collection.name)}
646
709
  onRemove={() => setRemoveDialog(collection)}
@@ -13,6 +13,7 @@ import {
13
13
  LinkIcon,
14
14
  Loader2Icon,
15
15
  PencilIcon,
16
+ Share2Icon,
16
17
  SquareArrowOutUpRightIcon,
17
18
  TextIcon,
18
19
  TrashIcon,
@@ -63,6 +64,10 @@ import {
63
64
  parseDocumentDeepLink,
64
65
  } from "../lib/deep-links";
65
66
  import { waitForDocumentAvailability } from "../lib/document-availability";
67
+ import {
68
+ downloadPublishArtifactFile,
69
+ type PublishExportResponse,
70
+ } from "../lib/publish-export";
66
71
  import { subscribeWorkspaceActionRequest } from "../lib/workspace-events";
67
72
 
68
73
  interface PageProps {
@@ -287,6 +292,11 @@ export default function DocView({ navigate }: PageProps) {
287
292
  const [externalChangeNotice, setExternalChangeNotice] = useState<
288
293
  string | null
289
294
  >(null);
295
+ const [exportingPublishArtifact, setExportingPublishArtifact] =
296
+ useState(false);
297
+ const [publishExportError, setPublishExportError] = useState<string | null>(
298
+ null
299
+ );
290
300
 
291
301
  // Tag editing state
292
302
  const [editingTags, setEditingTags] = useState(false);
@@ -518,6 +528,32 @@ export default function DocView({ navigate }: PageProps) {
518
528
  }
519
529
  }, [doc, navigate]);
520
530
 
531
+ const handlePublishExport = useCallback(async () => {
532
+ if (!doc) {
533
+ return;
534
+ }
535
+
536
+ setPublishExportError(null);
537
+ setExportingPublishArtifact(true);
538
+ const { data, error: err } = await apiFetch<PublishExportResponse>(
539
+ "/api/publish/export",
540
+ {
541
+ body: JSON.stringify({ target: doc.uri }),
542
+ method: "POST",
543
+ }
544
+ );
545
+ setExportingPublishArtifact(false);
546
+
547
+ if (err) {
548
+ setPublishExportError(err);
549
+ return;
550
+ }
551
+
552
+ if (data) {
553
+ downloadPublishArtifactFile(data);
554
+ }
555
+ }, [doc]);
556
+
521
557
  const handleDelete = async () => {
522
558
  if (!doc) return;
523
559
 
@@ -1309,6 +1345,22 @@ export default function DocView({ navigate }: PageProps) {
1309
1345
  <PencilIcon className="size-4" />
1310
1346
  Edit
1311
1347
  </Button>
1348
+ <Button
1349
+ className="gap-1.5"
1350
+ disabled={exportingPublishArtifact}
1351
+ onClick={() => {
1352
+ void handlePublishExport();
1353
+ }}
1354
+ size="sm"
1355
+ variant="outline"
1356
+ >
1357
+ {exportingPublishArtifact ? (
1358
+ <Loader2Icon className="size-4 animate-spin" />
1359
+ ) : (
1360
+ <Share2Icon className="size-4" />
1361
+ )}
1362
+ Export for gno.sh
1363
+ </Button>
1312
1364
  <Button
1313
1365
  className="gap-1.5"
1314
1366
  onClick={handleStartRename}
@@ -1356,6 +1408,22 @@ export default function DocView({ navigate }: PageProps) {
1356
1408
  Create editable copy
1357
1409
  </Button>
1358
1410
  )}
1411
+ <Button
1412
+ className="gap-1.5"
1413
+ disabled={exportingPublishArtifact}
1414
+ onClick={() => {
1415
+ void handlePublishExport();
1416
+ }}
1417
+ size="sm"
1418
+ variant="outline"
1419
+ >
1420
+ {exportingPublishArtifact ? (
1421
+ <Loader2Icon className="size-4 animate-spin" />
1422
+ ) : (
1423
+ <Share2Icon className="size-4" />
1424
+ )}
1425
+ Export for gno.sh
1426
+ </Button>
1359
1427
  {doc.source.absPath && (
1360
1428
  <>
1361
1429
  <Button
@@ -1408,6 +1476,9 @@ export default function DocView({ navigate }: PageProps) {
1408
1476
  </>
1409
1477
  )}
1410
1478
  </div>
1479
+ {publishExportError && (
1480
+ <p className="pt-2 text-destructive text-sm">{publishExportError}</p>
1481
+ )}
1411
1482
  </header>
1412
1483
 
1413
1484
  <div className="mx-auto flex max-w-[1800px] gap-5 px-6 xl:px-8">