@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.
package/README.md CHANGED
@@ -8,6 +8,9 @@
8
8
  [![Twitter](./assets/badges/twitter.svg)](https://twitter.com/gmickel)
9
9
  [![Discord](./assets/badges/discord.svg)](https://discord.gg/nHEmyJB5tg)
10
10
 
11
+ > [!TIP]
12
+ > **[gno.sh/publish](https://gno.sh/publish) is live.** Turn any GNO note or collection into a polished, reader-first URL — editorial typography, scoped search, and four visibility modes from public to end-to-end encrypted. **[See the reader →](#publish-to-gnosh)**
13
+
11
14
  > **ClawdHub**: GNO skills bundled for Clawdbot — [clawdhub.com/gmickel/gno](https://clawdhub.com/gmickel/gno)
12
15
 
13
16
  ![GNO](./assets/og-image.png)
@@ -27,6 +30,7 @@ Use it when:
27
30
  - **Real retrieval surfaces**: CLI, Web UI, REST API, MCP, SDK
28
31
  - **Local-first answers**: grounded synthesis with citations when you want answers, raw retrieval when you do not
29
32
  - **Connected knowledge**: backlinks, related notes, graph view, cross-collection navigation
33
+ - **Shareable, not synced**: export a note or collection to [gno.sh](https://gno.sh/publish) as a polished reader page — public, secret, invite-only, or end-to-end encrypted
30
34
  - **Operational fit**: daemon mode, model presets, remote GPU backends, safe config/state on disk
31
35
 
32
36
  ### One-Minute Tour
@@ -74,6 +78,7 @@ gno daemon
74
78
  - [Search Modes](#search-modes)
75
79
  - [Agent Integration](#agent-integration)
76
80
  - [Web UI](#web-ui)
81
+ - [Publish to gno.sh](#publish-to-gnosh)
77
82
  - [REST API](#rest-api)
78
83
  - [SDK](#sdk)
79
84
  - [How It Works](#how-it-works)
@@ -90,6 +95,7 @@ gno daemon
90
95
  > Latest release: [v0.40.2](./CHANGELOG.md#0402---2026-04-06)
91
96
  > Full release history: [CHANGELOG.md](./CHANGELOG.md)
92
97
 
98
+ - **Publish to [gno.sh](https://gno.sh/publish)**: new `gno publish export` CLI and Web UI action produce a self-contained artifact you upload to the hosted reader — public, secret, invite-only, or end-to-end encrypted
93
99
  - **Retrieval Quality Upgrade**: stronger BM25 lexical handling, code-aware chunking, terminal result hyperlinks, and per-collection model overrides
94
100
  - **Code Embedding Benchmarks**: new benchmark workflow across canonical, real-GNO, and pinned OSS slices for comparing alternate embedding models
95
101
  - **Default Embed Model**: built-in presets now use `Qwen3-Embedding-0.6B-GGUF` after it beat `bge-m3` on both code and multilingual prose benchmark lanes
@@ -410,8 +416,22 @@ gno query "refresh token rotation" --explain
410
416
  # Work with filters
411
417
  gno query "meeting notes" --since "last month" --category "meeting,notes"
412
418
  gno search "incident review" --tags-all "status/active,team/platform"
419
+
420
+ # Export a publish artifact for gno.sh
421
+ gno publish export work-docs --out ~/Downloads/work-docs.json
422
+ gno publish export "gno://work-docs/runbooks/deploy.md" --out ~/Downloads/deploy.json
423
+ # Or let GNO choose ~/Downloads/<slug>-<YYYYMMDD>.json automatically
424
+ gno publish export work-docs
413
425
  ```
414
426
 
427
+ The local web UI exposes the same export flow:
428
+
429
+ - Collections page → collection menu → `Export for gno.sh`
430
+ - Document view → `Export for gno.sh`
431
+
432
+ Both actions download the same JSON artifact the CLI writes, ready for upload at
433
+ `https://gno.sh/studio`.
434
+
415
435
  ### Retrieval V2 Controls
416
436
 
417
437
  Existing query calls still work. Retrieval v2 adds optional structured intent control and deeper explain output.
@@ -590,6 +610,47 @@ Everything runs locally. No cloud, no accounts, no data leaving your machine.
590
610
 
591
611
  ---
592
612
 
613
+ ## Publish to gno.sh
614
+
615
+ GNO is local-first, but sometimes you want a URL to send someone. [**gno.sh**](https://gno.sh/publish) is the hosted reader on top of GNO — a polished, reading-first page for a single note or a whole collection, without mounting your vault or syncing anything.
616
+
617
+ ![gno.sh publish reader](./assets/screenshots/publish-reader.jpg)
618
+
619
+ The workflow is deliberately explicit: **export locally → upload artifact → share URL**. Your private notes and metadata stay on your machine. Only what you export leaves.
620
+
621
+ ```bash
622
+ # Export a single note
623
+ gno publish export "gno://work-docs/runbooks/deploy.md" --out ~/Downloads/deploy.json
624
+
625
+ # Export a whole collection
626
+ gno publish export work-docs --out ~/Downloads/work-docs.json
627
+
628
+ # Let GNO pick the path (~/Downloads/<slug>-<YYYYMMDD>.json)
629
+ gno publish export work-docs
630
+ ```
631
+
632
+ Or use the Web UI:
633
+
634
+ - **Collections page** → collection menu → **Export for gno.sh**
635
+ - **Document view** → **Export for gno.sh**
636
+
637
+ Upload the artifact at [gno.sh/studio](https://gno.sh/studio) and pick a visibility mode:
638
+
639
+ | Mode | Use When |
640
+ | :----------------------- | :-------------------------------------------------- |
641
+ | **Public** | Open URL, indexable — talks, blog posts, portfolios |
642
+ | **Secret link** | Unguessable token, rotate / revoke / expire |
643
+ | **Invite-only** | Private space for specific people |
644
+ | **End-to-end encrypted** | WebCrypto bundle, passphrase decrypts in-browser |
645
+
646
+ **Reader experience**: editorial serif typography, drop caps, hanging punctuation, table of contents, keyboard shortcuts (`j/k`, `/`), scoped Pagefind-style search, and backlinks restricted to the published subset. Nothing leaks that you didn't publish.
647
+
648
+ Republishing an artifact updates the same URL — no churn for your readers.
649
+
650
+ > **Full story**: [gno.sh/publish](https://gno.sh/publish) · **Try it**: [gno.sh/studio](https://gno.sh/studio)
651
+
652
+ ---
653
+
593
654
  ## REST API
594
655
 
595
656
  Programmatic access to all GNO features via HTTP.
@@ -22,6 +22,7 @@ Fast local semantic search. Index once, search instantly. No cloud, no API keys.
22
22
  - User wants to **tag, categorize, or filter** documents
23
23
  - User asks about **backlinks, wiki links, or related notes**
24
24
  - User wants to **visualize document connections** or see a **knowledge graph**
25
+ - User wants to **export a note or collection for gno.sh publishing**
25
26
 
26
27
  ## Quick Start
27
28
 
@@ -44,6 +45,7 @@ gno search "your query" # BM25 keyword search
44
45
  | **Context** | `context add/list/rm/check` | Add hints to improve search relevance |
45
46
  | **Models** | `models list/use/pull/clear/path` | Manage local AI models |
46
47
  | **Serve** | `serve` | Web UI for browsing and searching |
48
+ | **Publish** | `publish export` | Export gno.sh publish artifacts |
47
49
  | **MCP** | `mcp`, `mcp install/uninstall/status` | AI assistant integration |
48
50
  | **Skill** | `skill install/uninstall/show/paths` | Install skill for AI agents |
49
51
  | **Admin** | `status`, `doctor`, `cleanup`, `reset`, `vec`, `completion` | Maintenance and diagnostics |
@@ -494,6 +494,34 @@ gno serve [options]
494
494
 
495
495
  Features: Dashboard, search, browse collections, document viewer, AI Q&A with citations.
496
496
 
497
+ ## Publish
498
+
499
+ ### gno publish export
500
+
501
+ Export a note or collection as a gno.sh publish artifact JSON.
502
+
503
+ ```bash
504
+ gno publish export <target> [--out <path.json>] [options]
505
+ ```
506
+
507
+ | Option | Default | Description |
508
+ | -------------- | ------- | ------------------------------------------------------------- |
509
+ | `--out` | auto | Output path, defaults to `~/Downloads/<slug>-<YYYYMMDD>.json` |
510
+ | `--visibility` | public | One of `public`, `secret-link`, `invite-only`, `encrypted` |
511
+ | `--slug` | auto | Override the published route slug |
512
+ | `--title` | auto | Override the exported title |
513
+ | `--summary` | auto | Override the exported summary |
514
+ | `--json` | false | Structured result output |
515
+
516
+ Examples:
517
+
518
+ ```bash
519
+ gno publish export work-docs --out ~/Downloads/work-docs.json
520
+ gno publish export "gno://work-docs/runbooks/deploy.md" --out ~/Downloads/deploy.json
521
+ ```
522
+
523
+ On success, upload the JSON file at `https://gno.sh/studio`.
524
+
497
525
  ## Skill Management
498
526
 
499
527
  ### gno skill install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "0.41.1",
3
+ "version": "0.42.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",
@@ -88,6 +88,12 @@ export {
88
88
  type StatusResult,
89
89
  status,
90
90
  } from "./status";
91
+ export {
92
+ formatPublishExport,
93
+ publishExport,
94
+ type PublishExportOptions,
95
+ type PublishExportResult,
96
+ } from "./publish";
91
97
  export {
92
98
  formatUpdate,
93
99
  type UpdateOptions,
@@ -0,0 +1,140 @@
1
+ /**
2
+ * gno publish export command.
3
+ *
4
+ * @module src/cli/commands/publish
5
+ */
6
+
7
+ import { mkdir, writeFile } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { dirname } from "node:path";
10
+ import { join } from "node:path";
11
+
12
+ import type {
13
+ PublishArtifact,
14
+ PublishVisibility,
15
+ } from "../../publish/artifact";
16
+
17
+ import { derivePublishArtifactFilename, slugify } from "../../publish/artifact";
18
+ import { exportPublishArtifact } from "../../publish/export-service";
19
+ import { initStore } from "./shared";
20
+
21
+ export interface PublishExportOptions {
22
+ configPath?: string;
23
+ json?: boolean;
24
+ out?: string;
25
+ slug?: string;
26
+ summary?: string;
27
+ title?: string;
28
+ visibility?: PublishVisibility;
29
+ }
30
+
31
+ export type PublishExportResult =
32
+ | {
33
+ success: true;
34
+ data: {
35
+ artifact: PublishArtifact;
36
+ outPath: string;
37
+ uploadUrl: string;
38
+ };
39
+ }
40
+ | { success: false; error: string; isValidation?: boolean };
41
+
42
+ function formatExportDateStamp(isoTimestamp: string): string {
43
+ return isoTimestamp.slice(0, 10).replaceAll("-", "");
44
+ }
45
+
46
+ export function buildDefaultPublishExportPath(
47
+ artifact: PublishArtifact
48
+ ): string {
49
+ const fileName = derivePublishArtifactFilename(artifact).replace(
50
+ /\.json$/u,
51
+ ""
52
+ );
53
+ return join(
54
+ homedir(),
55
+ "Downloads",
56
+ `${fileName}-${formatExportDateStamp(artifact.exportedAt)}.json`
57
+ );
58
+ }
59
+
60
+ export async function publishExport(
61
+ target: string,
62
+ options: PublishExportOptions
63
+ ): Promise<PublishExportResult> {
64
+ const initResult = await initStore({
65
+ configPath: options.configPath,
66
+ syncConfig: false,
67
+ });
68
+ if (!initResult.ok) {
69
+ return { success: false, error: initResult.error };
70
+ }
71
+
72
+ const { collections, store } = initResult;
73
+
74
+ try {
75
+ const artifact = await exportPublishArtifact({
76
+ collections,
77
+ options: {
78
+ routeSlug: options.slug,
79
+ summary: options.summary,
80
+ title: options.title,
81
+ visibility: options.visibility,
82
+ },
83
+ store,
84
+ target,
85
+ });
86
+ const outPath =
87
+ options.out?.trim() || buildDefaultPublishExportPath(artifact);
88
+
89
+ await mkdir(dirname(outPath), { recursive: true });
90
+ await writeFile(outPath, JSON.stringify(artifact, null, 2));
91
+
92
+ return {
93
+ success: true,
94
+ data: {
95
+ artifact,
96
+ outPath,
97
+ uploadUrl: "https://gno.sh/studio",
98
+ },
99
+ };
100
+ } catch (error) {
101
+ return {
102
+ success: false,
103
+ error: error instanceof Error ? error.message : String(error),
104
+ };
105
+ } finally {
106
+ await store.close();
107
+ }
108
+ }
109
+
110
+ export function formatPublishExport(
111
+ result: PublishExportResult,
112
+ options: Pick<PublishExportOptions, "json">
113
+ ): string {
114
+ if (!result.success) {
115
+ if (options.json) {
116
+ return JSON.stringify({
117
+ error: {
118
+ code: result.isValidation ? "VALIDATION" : "RUNTIME",
119
+ message: result.error,
120
+ },
121
+ });
122
+ }
123
+ return `Error: ${result.error}`;
124
+ }
125
+
126
+ if (options.json) {
127
+ return JSON.stringify(result.data, null, 2);
128
+ }
129
+
130
+ const { artifact, outPath, uploadUrl } = result.data;
131
+ const space = artifact.spaces[0];
132
+
133
+ return [
134
+ `Exported ${space?.sourceType ?? "artifact"} to ${outPath}`,
135
+ `Route slug: ${space?.routeSlug ?? slugify(artifact.source)}`,
136
+ `Visibility: ${space?.visibility ?? "public"}`,
137
+ `Filename: ${derivePublishArtifactFilename(artifact)}`,
138
+ `Next: open ${uploadUrl} and drop ${outPath} into the upload zone.`,
139
+ ].join("\n");
140
+ }
@@ -27,6 +27,7 @@ export const CMD = {
27
27
  multiGet: "multi-get",
28
28
  ls: "ls",
29
29
  status: "status",
30
+ publishExport: "publish.export",
30
31
  collectionList: "collection.list",
31
32
  contextList: "context.list",
32
33
  contextCheck: "context.check",
@@ -49,6 +50,7 @@ const FORMAT_SUPPORT: Record<CommandId, OutputFormat[]> = {
49
50
  [CMD.multiGet]: ["terminal", "json", "files", "md"],
50
51
  [CMD.ls]: ["terminal", "json", "files", "md"],
51
52
  [CMD.status]: ["terminal", "json"],
53
+ [CMD.publishExport]: ["terminal", "json"],
52
54
  [CMD.collectionList]: ["terminal", "json", "md"],
53
55
  [CMD.contextList]: ["terminal", "json", "md"],
54
56
  [CMD.contextCheck]: ["terminal", "json", "md"],
@@ -216,6 +216,7 @@ export function createProgram(): Command {
216
216
  wireSearchCommands(program);
217
217
  wireOnboardingCommands(program);
218
218
  wireManagementCommands(program);
219
+ wirePublishCommand(program);
219
220
  wireVecCommands(program);
220
221
  wireRetrievalCommands(program);
221
222
  wireTagsCommands(program);
@@ -1623,6 +1624,77 @@ function wireVecCommands(program: Command): void {
1623
1624
  });
1624
1625
  }
1625
1626
 
1627
+ function wirePublishCommand(program: Command): void {
1628
+ const visibilityValues = [
1629
+ "public",
1630
+ "secret-link",
1631
+ "invite-only",
1632
+ "encrypted",
1633
+ ] as const;
1634
+ const publishCmd = program
1635
+ .command("publish")
1636
+ .description("Export publish artifacts for gno.sh");
1637
+
1638
+ publishCmd
1639
+ .command("export <target>")
1640
+ .description("Export a collection or document ref as a gno.sh artifact")
1641
+ .option("--out <path>", "output artifact path")
1642
+ .option(
1643
+ "--visibility <mode>",
1644
+ "visibility hint (public, secret-link, invite-only, encrypted)",
1645
+ "public"
1646
+ )
1647
+ .option("--slug <slug>", "route slug override")
1648
+ .option("--title <title>", "space title override")
1649
+ .option("--summary <summary>", "space summary override")
1650
+ .option("--json", "JSON output")
1651
+ .action(async (target: string, cmdOpts: Record<string, unknown>) => {
1652
+ const format = getFormat(cmdOpts);
1653
+ assertFormatSupported(CMD.publishExport, format);
1654
+ const globals = getGlobals();
1655
+
1656
+ const visibility = cmdOpts.visibility as string;
1657
+ if (
1658
+ !visibilityValues.includes(
1659
+ visibility as (typeof visibilityValues)[number]
1660
+ )
1661
+ ) {
1662
+ throw new CliError(
1663
+ "VALIDATION",
1664
+ `Invalid visibility: ${visibility}. Must be public, secret-link, invite-only, or encrypted.`
1665
+ );
1666
+ }
1667
+
1668
+ const { formatPublishExport, publishExport } =
1669
+ await import("./commands/publish");
1670
+ const result = await publishExport(target, {
1671
+ configPath: globals.config,
1672
+ json: format === "json",
1673
+ out: typeof cmdOpts.out === "string" ? cmdOpts.out : undefined,
1674
+ slug: cmdOpts.slug as string | undefined,
1675
+ summary: cmdOpts.summary as string | undefined,
1676
+ title: cmdOpts.title as string | undefined,
1677
+ visibility: visibility as
1678
+ | "encrypted"
1679
+ | "invite-only"
1680
+ | "public"
1681
+ | "secret-link",
1682
+ });
1683
+
1684
+ if (!result.success) {
1685
+ throw new CliError(
1686
+ result.isValidation ? "VALIDATION" : "RUNTIME",
1687
+ result.error
1688
+ );
1689
+ }
1690
+
1691
+ await writeOutput(
1692
+ formatPublishExport(result, { json: format === "json" }),
1693
+ format
1694
+ );
1695
+ });
1696
+ }
1697
+
1626
1698
  // ─────────────────────────────────────────────────────────────────────────────
1627
1699
  // Skill Commands (install, uninstall, show, paths)
1628
1700
  // ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Publish artifact types and builders for gno.sh export.
3
+ *
4
+ * @module src/publish/artifact
5
+ */
6
+
7
+ import type { DocumentRow, TagRow } from "../store/types";
8
+
9
+ import { stripFrontmatter } from "../ingestion/frontmatter";
10
+
11
+ export type PublishVisibility =
12
+ | "encrypted"
13
+ | "invite-only"
14
+ | "public"
15
+ | "secret-link";
16
+
17
+ export interface PublishArtifactNote {
18
+ markdown: string;
19
+ metadata?: Record<string, string | string[]>;
20
+ slug: string;
21
+ summary: string;
22
+ title: string;
23
+ }
24
+
25
+ export interface PublishArtifactSpace {
26
+ homeNoteSlug?: string;
27
+ notes: PublishArtifactNote[];
28
+ routeSlug: string;
29
+ sourceType: "note" | "collection";
30
+ summary: string;
31
+ title: string;
32
+ visibility: PublishVisibility;
33
+ }
34
+
35
+ export interface PublishArtifact {
36
+ exportedAt: string;
37
+ source: string;
38
+ spaces: PublishArtifactSpace[];
39
+ version: 1;
40
+ }
41
+
42
+ export const PUBLISH_VISIBILITY_VALUES = [
43
+ "public",
44
+ "secret-link",
45
+ "invite-only",
46
+ "encrypted",
47
+ ] as const;
48
+
49
+ export const MAX_PUBLISH_SLUG_LENGTH = 80;
50
+
51
+ const ALLOWED_FRONTMATTER_METADATA_KEYS = new Set([
52
+ "audience",
53
+ "canonical",
54
+ "canonicalUrl",
55
+ "canonicalURL",
56
+ "coverAlt",
57
+ "coverImage",
58
+ "icon",
59
+ "image",
60
+ "layout",
61
+ "publishedAt",
62
+ "readingTime",
63
+ "series",
64
+ "seriesOrder",
65
+ "status",
66
+ "subtitle",
67
+ "theme",
68
+ "topic",
69
+ "topics",
70
+ ]);
71
+
72
+ export const isPublishVisibility = (
73
+ value: unknown
74
+ ): value is PublishVisibility =>
75
+ typeof value === "string" &&
76
+ PUBLISH_VISIBILITY_VALUES.includes(value as PublishVisibility);
77
+
78
+ export const slugify = (value: string) =>
79
+ value
80
+ .toLowerCase()
81
+ .replace(/[^a-z0-9]+/g, "-")
82
+ .replace(/(^-|-$)/g, "");
83
+
84
+ function toPublishSlugCandidate(value: string): string {
85
+ return slugify(value).slice(0, MAX_PUBLISH_SLUG_LENGTH).replace(/-+$/g, "");
86
+ }
87
+
88
+ export function derivePublishSlug(
89
+ candidates: Array<string>,
90
+ fallback = "untitled"
91
+ ): string {
92
+ for (const candidate of candidates) {
93
+ const slug = toPublishSlugCandidate(candidate);
94
+ if (slug.length > 0) {
95
+ return slug;
96
+ }
97
+ }
98
+
99
+ return fallback;
100
+ }
101
+
102
+ export const normalizePublishSlug = (value: string, fallback?: string) =>
103
+ derivePublishSlug([value], fallback);
104
+
105
+ const basenameWithoutExt = (value: string) =>
106
+ value
107
+ .split("/")
108
+ .pop()
109
+ ?.replace(/\.[^.]+$/, "") ?? value;
110
+
111
+ export const deriveExportedTitle = (
112
+ doc: Pick<DocumentRow, "relPath" | "title">
113
+ ) => doc.title?.trim() || basenameWithoutExt(doc.relPath);
114
+
115
+ export const deriveExportedSlug = (
116
+ doc: Pick<DocumentRow, "relPath" | "title">
117
+ ) =>
118
+ derivePublishSlug([
119
+ deriveExportedTitle(doc),
120
+ doc.relPath.replace(/\.[^.]+$/, "").replaceAll("/", "-"),
121
+ ]);
122
+
123
+ export const deriveExportedSummary = (
124
+ markdown: string,
125
+ metadata: Record<string, unknown>
126
+ ) => {
127
+ const metadataSummary =
128
+ typeof metadata.description === "string"
129
+ ? metadata.description
130
+ : typeof metadata.summary === "string"
131
+ ? metadata.summary
132
+ : null;
133
+ if (metadataSummary?.trim()) {
134
+ return metadataSummary.trim();
135
+ }
136
+
137
+ const plain = stripFrontmatter(markdown)
138
+ .split("\n")
139
+ .map((line) => line.trim())
140
+ .filter(
141
+ (line) =>
142
+ line.length > 0 &&
143
+ !line.startsWith("#") &&
144
+ !line.startsWith("!") &&
145
+ !line.startsWith("```")
146
+ )
147
+ .join(" ")
148
+ .replace(/\s+/g, " ")
149
+ .trim();
150
+
151
+ return plain.slice(0, 200).trim();
152
+ };
153
+
154
+ export const buildExportedMetadata = (
155
+ doc: Pick<
156
+ DocumentRow,
157
+ | "author"
158
+ | "categories"
159
+ | "collection"
160
+ | "contentType"
161
+ | "frontmatterDate"
162
+ | "languageHint"
163
+ | "relPath"
164
+ >,
165
+ parsedFrontmatter: Record<string, unknown>,
166
+ tags: TagRow[]
167
+ ) => {
168
+ const metadata: Record<string, string | string[]> = {};
169
+
170
+ if (doc.author) {
171
+ metadata.author = doc.author;
172
+ }
173
+ if (doc.contentType) {
174
+ metadata.contentType = doc.contentType;
175
+ }
176
+ if (doc.languageHint) {
177
+ metadata.language = doc.languageHint;
178
+ }
179
+ if (doc.frontmatterDate) {
180
+ metadata.date = doc.frontmatterDate;
181
+ }
182
+ if (doc.categories?.length) {
183
+ metadata.categories = doc.categories;
184
+ }
185
+
186
+ const tagValues = tags.map((tag) => tag.tag);
187
+ if (tagValues.length) {
188
+ metadata.tags = tagValues;
189
+ }
190
+
191
+ metadata.collection = doc.collection;
192
+ metadata.sourceRelPath = doc.relPath;
193
+
194
+ for (const [key, value] of Object.entries(parsedFrontmatter)) {
195
+ if (
196
+ key === "tags" ||
197
+ key === "title" ||
198
+ key === "summary" ||
199
+ !ALLOWED_FRONTMATTER_METADATA_KEYS.has(key)
200
+ ) {
201
+ continue;
202
+ }
203
+ if (typeof value === "string" && value.trim()) {
204
+ metadata[key] = value.trim();
205
+ continue;
206
+ }
207
+ if (Array.isArray(value)) {
208
+ const cleaned = value
209
+ .filter((entry): entry is string => typeof entry === "string")
210
+ .map((entry) => entry.trim())
211
+ .filter(Boolean);
212
+ if (cleaned.length > 0) {
213
+ metadata[key] = cleaned;
214
+ }
215
+ }
216
+ }
217
+
218
+ return metadata;
219
+ };
220
+
221
+ export const buildPublishArtifact = (input: {
222
+ homeNoteSlug?: string;
223
+ notes: PublishArtifactNote[];
224
+ routeSlug: string;
225
+ source: string;
226
+ sourceType: "note" | "collection";
227
+ summary: string;
228
+ title: string;
229
+ visibility: PublishVisibility;
230
+ }) => ({
231
+ exportedAt: new Date().toISOString(),
232
+ source: input.source,
233
+ spaces: [
234
+ {
235
+ homeNoteSlug: input.homeNoteSlug,
236
+ notes: input.notes,
237
+ routeSlug: input.routeSlug,
238
+ sourceType: input.sourceType,
239
+ summary: input.summary,
240
+ title: input.title,
241
+ visibility: input.visibility,
242
+ },
243
+ ],
244
+ version: 1 as const,
245
+ });
246
+
247
+ export const derivePublishArtifactFilename = (artifact: PublishArtifact) => {
248
+ const routeSlug =
249
+ artifact.spaces[0]?.routeSlug.trim() ||
250
+ normalizePublishSlug(artifact.source, "publish-artifact");
251
+ return `${routeSlug || "publish-artifact"}.json`;
252
+ };