@gmickel/gno 0.41.0 → 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 +70 -0
- package/assets/screenshots/publish-reader.jpg +0 -0
- package/assets/skill/SKILL.md +2 -0
- package/assets/skill/cli-reference.md +28 -0
- package/package.json +1 -1
- package/src/cli/commands/embed.ts +216 -8
- package/src/cli/commands/index.ts +6 -0
- package/src/cli/commands/publish.ts +140 -0
- package/src/cli/options.ts +2 -0
- package/src/cli/program.ts +72 -0
- package/src/embed/batch.ts +154 -3
- package/src/publish/artifact.ts +252 -0
- package/src/publish/export-service.ts +238 -0
- package/src/serve/AGENTS.md +17 -16
- package/src/serve/CLAUDE.md +17 -16
- package/src/serve/public/lib/publish-export.ts +21 -0
- package/src/serve/public/pages/Collections.tsx +63 -0
- package/src/serve/public/pages/DocView.tsx +71 -0
- package/src/serve/routes/api.ts +82 -0
- package/src/serve/server.ts +12 -0
- package/src/store/vector/sqlite-vec.ts +11 -6
package/src/cli/options.ts
CHANGED
|
@@ -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"],
|
package/src/cli/program.ts
CHANGED
|
@@ -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
|
// ─────────────────────────────────────────────────────────────────────────────
|
package/src/embed/batch.ts
CHANGED
|
@@ -14,8 +14,12 @@ export interface EmbedBatchRecoveryResult {
|
|
|
14
14
|
batchFailed: boolean;
|
|
15
15
|
batchError?: string;
|
|
16
16
|
fallbackErrors: number;
|
|
17
|
+
failureSamples: string[];
|
|
18
|
+
retrySuggestion?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
const MAX_FAILURE_SAMPLES = 5;
|
|
22
|
+
|
|
19
23
|
function errorMessage(error: unknown): string {
|
|
20
24
|
if (
|
|
21
25
|
error &&
|
|
@@ -28,6 +32,27 @@ function errorMessage(error: unknown): string {
|
|
|
28
32
|
return String(error);
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
function formatFailureMessage(error: {
|
|
36
|
+
message: string;
|
|
37
|
+
cause?: unknown;
|
|
38
|
+
}): string {
|
|
39
|
+
const cause = error.cause ? errorMessage(error.cause) : "";
|
|
40
|
+
return cause && cause !== error.message
|
|
41
|
+
? `${error.message} - ${cause}`
|
|
42
|
+
: error.message;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isDisposedFailure(message: string): boolean {
|
|
46
|
+
return message.toLowerCase().includes("object is disposed");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function resetEmbeddingPort(
|
|
50
|
+
embedPort: EmbeddingPort
|
|
51
|
+
): Promise<LlmResult<void>> {
|
|
52
|
+
await embedPort.dispose();
|
|
53
|
+
return embedPort.init();
|
|
54
|
+
}
|
|
55
|
+
|
|
31
56
|
export async function embedTextsWithRecovery(
|
|
32
57
|
embedPort: EmbeddingPort,
|
|
33
58
|
texts: string[]
|
|
@@ -39,13 +64,24 @@ export async function embedTextsWithRecovery(
|
|
|
39
64
|
vectors: [],
|
|
40
65
|
batchFailed: false,
|
|
41
66
|
fallbackErrors: 0,
|
|
67
|
+
failureSamples: [],
|
|
42
68
|
},
|
|
43
69
|
};
|
|
44
70
|
}
|
|
45
71
|
|
|
46
72
|
const profile = getEmbeddingCompatibilityProfile(embedPort.modelUri);
|
|
47
73
|
if (profile.batchEmbeddingTrusted) {
|
|
48
|
-
|
|
74
|
+
let batchResult = await embedPort.embedBatch(texts);
|
|
75
|
+
if (!batchResult.ok) {
|
|
76
|
+
const formattedBatchError = formatFailureMessage(batchResult.error);
|
|
77
|
+
if (isDisposedFailure(formattedBatchError)) {
|
|
78
|
+
const reset = await resetEmbeddingPort(embedPort);
|
|
79
|
+
if (!reset.ok) {
|
|
80
|
+
return reset;
|
|
81
|
+
}
|
|
82
|
+
batchResult = await embedPort.embedBatch(texts);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
49
85
|
if (batchResult.ok && batchResult.value.length === texts.length) {
|
|
50
86
|
return {
|
|
51
87
|
ok: true,
|
|
@@ -53,11 +89,14 @@ export async function embedTextsWithRecovery(
|
|
|
53
89
|
vectors: batchResult.value,
|
|
54
90
|
batchFailed: false,
|
|
55
91
|
fallbackErrors: 0,
|
|
92
|
+
failureSamples: [],
|
|
56
93
|
},
|
|
57
94
|
};
|
|
58
95
|
}
|
|
59
96
|
|
|
60
|
-
const recovered = await
|
|
97
|
+
const recovered = await recoverWithAdaptiveBatches(embedPort, texts, {
|
|
98
|
+
rootBatchAlreadyFailed: true,
|
|
99
|
+
});
|
|
61
100
|
if (!recovered.ok) {
|
|
62
101
|
return recovered;
|
|
63
102
|
}
|
|
@@ -68,7 +107,11 @@ export async function embedTextsWithRecovery(
|
|
|
68
107
|
batchFailed: true,
|
|
69
108
|
batchError: batchResult.ok
|
|
70
109
|
? `Embedding count mismatch: got ${batchResult.value.length}, expected ${texts.length}`
|
|
71
|
-
: batchResult.error
|
|
110
|
+
: formatFailureMessage(batchResult.error),
|
|
111
|
+
retrySuggestion:
|
|
112
|
+
recovered.value.fallbackErrors > 0
|
|
113
|
+
? "Try rerunning the same command. If failures persist, rerun with `gno --verbose embed --batch-size 1` to isolate failing chunks."
|
|
114
|
+
: undefined,
|
|
72
115
|
},
|
|
73
116
|
};
|
|
74
117
|
}
|
|
@@ -83,10 +126,113 @@ export async function embedTextsWithRecovery(
|
|
|
83
126
|
...recovered.value,
|
|
84
127
|
batchFailed: true,
|
|
85
128
|
batchError: "Batch embedding disabled for this compatibility profile",
|
|
129
|
+
retrySuggestion:
|
|
130
|
+
recovered.value.fallbackErrors > 0
|
|
131
|
+
? "Some chunks still failed individually. Rerun with `gno --verbose embed --batch-size 1` for exact chunk errors."
|
|
132
|
+
: undefined,
|
|
86
133
|
},
|
|
87
134
|
};
|
|
88
135
|
}
|
|
89
136
|
|
|
137
|
+
async function recoverWithAdaptiveBatches(
|
|
138
|
+
embedPort: EmbeddingPort,
|
|
139
|
+
texts: string[],
|
|
140
|
+
options: { rootBatchAlreadyFailed?: boolean } = {}
|
|
141
|
+
): Promise<
|
|
142
|
+
LlmResult<Omit<EmbedBatchRecoveryResult, "batchFailed" | "batchError">>
|
|
143
|
+
> {
|
|
144
|
+
try {
|
|
145
|
+
const vectors: Array<number[] | null> = Array.from(
|
|
146
|
+
{ length: texts.length },
|
|
147
|
+
() => null
|
|
148
|
+
);
|
|
149
|
+
const failureSamples: string[] = [];
|
|
150
|
+
let fallbackErrors = 0;
|
|
151
|
+
|
|
152
|
+
const recordFailure = (message: string): void => {
|
|
153
|
+
if (failureSamples.length < MAX_FAILURE_SAMPLES) {
|
|
154
|
+
failureSamples.push(message);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const processRange = async (
|
|
159
|
+
rangeTexts: string[],
|
|
160
|
+
offset: number,
|
|
161
|
+
batchAlreadyFailed = false
|
|
162
|
+
): Promise<void> => {
|
|
163
|
+
if (rangeTexts.length === 0) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (rangeTexts.length === 1) {
|
|
168
|
+
const result = await embedPort.embed(rangeTexts[0] ?? "");
|
|
169
|
+
if (result.ok) {
|
|
170
|
+
vectors[offset] = result.value;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
fallbackErrors += 1;
|
|
174
|
+
recordFailure(formatFailureMessage(result.error));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let batchResult: Awaited<ReturnType<typeof embedPort.embedBatch>> | null =
|
|
179
|
+
null;
|
|
180
|
+
if (!batchAlreadyFailed) {
|
|
181
|
+
batchResult = await embedPort.embedBatch(rangeTexts);
|
|
182
|
+
}
|
|
183
|
+
if (
|
|
184
|
+
batchResult &&
|
|
185
|
+
batchResult.ok &&
|
|
186
|
+
batchResult.value.length === rangeTexts.length
|
|
187
|
+
) {
|
|
188
|
+
for (const [index, vector] of batchResult.value.entries()) {
|
|
189
|
+
vectors[offset + index] = vector;
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const mid = Math.ceil(rangeTexts.length / 2);
|
|
195
|
+
await processRange(rangeTexts.slice(0, mid), offset);
|
|
196
|
+
await processRange(rangeTexts.slice(mid), offset + mid);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
await processRange(texts, 0, options.rootBatchAlreadyFailed ?? false);
|
|
200
|
+
|
|
201
|
+
if (fallbackErrors === texts.length) {
|
|
202
|
+
const reinit = await resetEmbeddingPort(embedPort);
|
|
203
|
+
if (!reinit.ok) {
|
|
204
|
+
return reinit;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const retry = await recoverIndividually(embedPort, texts);
|
|
208
|
+
if (!retry.ok) {
|
|
209
|
+
return retry;
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
ok: true,
|
|
213
|
+
value: retry.value,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
value: {
|
|
220
|
+
vectors,
|
|
221
|
+
fallbackErrors,
|
|
222
|
+
failureSamples,
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
} catch (error) {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
error: inferenceFailedError(
|
|
229
|
+
embedPort.modelUri,
|
|
230
|
+
new Error(errorMessage(error))
|
|
231
|
+
),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
90
236
|
async function recoverIndividually(
|
|
91
237
|
embedPort: EmbeddingPort,
|
|
92
238
|
texts: string[]
|
|
@@ -95,6 +241,7 @@ async function recoverIndividually(
|
|
|
95
241
|
> {
|
|
96
242
|
try {
|
|
97
243
|
const vectors: Array<number[] | null> = [];
|
|
244
|
+
const failureSamples: string[] = [];
|
|
98
245
|
let fallbackErrors = 0;
|
|
99
246
|
|
|
100
247
|
for (const text of texts) {
|
|
@@ -104,6 +251,9 @@ async function recoverIndividually(
|
|
|
104
251
|
} else {
|
|
105
252
|
vectors.push(null);
|
|
106
253
|
fallbackErrors += 1;
|
|
254
|
+
if (failureSamples.length < MAX_FAILURE_SAMPLES) {
|
|
255
|
+
failureSamples.push(formatFailureMessage(result.error));
|
|
256
|
+
}
|
|
107
257
|
}
|
|
108
258
|
}
|
|
109
259
|
|
|
@@ -112,6 +262,7 @@ async function recoverIndividually(
|
|
|
112
262
|
value: {
|
|
113
263
|
vectors,
|
|
114
264
|
fallbackErrors,
|
|
265
|
+
failureSamples,
|
|
115
266
|
},
|
|
116
267
|
};
|
|
117
268
|
} catch (error) {
|
|
@@ -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
|
+
};
|