@gmickel/gno 1.0.0 → 1.0.3

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
@@ -653,6 +653,11 @@ Upload the artifact at [gno.sh/studio](https://gno.sh/studio) and pick a visibil
653
653
 
654
654
  Republishing a public, secret-link, or invite-only artifact updates the same URL. Encrypted shares should be replaced from a fresh local export so the server never needs your plaintext.
655
655
 
656
+ Encrypted source-backed publish on `gno.sh` is intentionally disabled. For encrypted shares, use:
657
+
658
+ - `gno publish export --visibility encrypted --passphrase ...`, or
659
+ - the browser-side encrypted markdown upload path in `gno.sh/studio`
660
+
656
661
  > **Full story**: [gno.sh/publish](https://gno.sh/publish) · **Try it**: [gno.sh/studio](https://gno.sh/studio)
657
662
 
658
663
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gmickel/gno",
3
- "version": "1.0.0",
3
+ "version": "1.0.3",
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",
@@ -13,9 +13,11 @@ import type {
13
13
  PublishArtifact,
14
14
  PublishVisibility,
15
15
  } from "../../publish/artifact";
16
+ import type { SanitizeWarning } from "../../publish/obsidian-sanitize";
16
17
 
17
18
  import { derivePublishArtifactFilename, slugify } from "../../publish/artifact";
18
19
  import { exportPublishArtifact } from "../../publish/export-service";
20
+ import { formatSanitizeWarnings } from "../../publish/obsidian-sanitize";
19
21
  import { initStore } from "./shared";
20
22
 
21
23
  export interface PublishExportOptions {
@@ -23,6 +25,7 @@ export interface PublishExportOptions {
23
25
  encryptionPassphrase?: string;
24
26
  json?: boolean;
25
27
  out?: string;
28
+ preview?: boolean;
26
29
  slug?: string;
27
30
  summary?: string;
28
31
  title?: string;
@@ -35,7 +38,10 @@ export type PublishExportResult =
35
38
  data: {
36
39
  artifact: PublishArtifact;
37
40
  outPath: string;
41
+ preview?: string;
38
42
  uploadUrl: string;
43
+ warnings: SanitizeWarning[];
44
+ warningsDisplay: string[];
39
45
  };
40
46
  }
41
47
  | { success: false; error: string; isValidation?: boolean };
@@ -73,7 +79,7 @@ export async function publishExport(
73
79
  const { collections, store } = initResult;
74
80
 
75
81
  try {
76
- const artifact = await exportPublishArtifact({
82
+ const { artifact, warnings } = await exportPublishArtifact({
77
83
  collections,
78
84
  options: {
79
85
  routeSlug: options.slug,
@@ -85,6 +91,28 @@ export async function publishExport(
85
91
  store,
86
92
  target,
87
93
  });
94
+ const warningsDisplay = formatSanitizeWarnings(warnings);
95
+
96
+ if (options.preview) {
97
+ const preview =
98
+ artifact.version === 1
99
+ ? (artifact.spaces[0]?.notes
100
+ .map((note) => `\n# ${note.title}\n\n${note.markdown.trim()}`)
101
+ .join("\n\n---\n") ?? "")
102
+ : "(Encrypted artifact — preview unavailable)";
103
+ return {
104
+ success: true,
105
+ data: {
106
+ artifact,
107
+ outPath: "",
108
+ preview,
109
+ uploadUrl: "https://gno.sh/studio",
110
+ warnings,
111
+ warningsDisplay,
112
+ },
113
+ };
114
+ }
115
+
88
116
  const outPath =
89
117
  options.out?.trim() || buildDefaultPublishExportPath(artifact);
90
118
 
@@ -97,6 +125,8 @@ export async function publishExport(
97
125
  artifact,
98
126
  outPath,
99
127
  uploadUrl: "https://gno.sh/studio",
128
+ warnings,
129
+ warningsDisplay,
100
130
  },
101
131
  };
102
132
  } catch (error) {
@@ -129,8 +159,25 @@ export function formatPublishExport(
129
159
  return JSON.stringify(result.data, null, 2);
130
160
  }
131
161
 
132
- const { artifact, outPath, uploadUrl } = result.data;
162
+ const { artifact, outPath, preview, uploadUrl, warningsDisplay } =
163
+ result.data;
133
164
  const space = artifact.spaces[0];
165
+ const warningsSection =
166
+ warningsDisplay.length > 0
167
+ ? ["", "Preprocessor notes:", ...warningsDisplay]
168
+ : [];
169
+
170
+ if (preview !== undefined) {
171
+ return [
172
+ `Preview (no file written) — ${space?.sourceType ?? "artifact"}`,
173
+ `Route slug: ${space?.routeSlug ?? slugify(artifact.source)}`,
174
+ `Visibility: ${space?.visibility ?? "public"}`,
175
+ ...warningsSection,
176
+ "",
177
+ "─── sanitized markdown ───",
178
+ preview.trim(),
179
+ ].join("\n");
180
+ }
134
181
 
135
182
  return [
136
183
  `Exported ${space?.sourceType ?? "artifact"} to ${outPath}`,
@@ -138,5 +185,6 @@ export function formatPublishExport(
138
185
  `Visibility: ${space?.visibility ?? "public"}`,
139
186
  `Filename: ${derivePublishArtifactFilename(artifact)}`,
140
187
  `Next: open ${uploadUrl} and drop ${outPath} into the upload zone.`,
188
+ ...warningsSection,
141
189
  ].join("\n");
142
190
  }
@@ -1651,6 +1651,10 @@ function wirePublishCommand(program: Command): void {
1651
1651
  .option("--slug <slug>", "route slug override")
1652
1652
  .option("--title <title>", "space title override")
1653
1653
  .option("--summary <summary>", "space summary override")
1654
+ .option(
1655
+ "--preview",
1656
+ "print sanitized markdown + warnings instead of writing the artifact"
1657
+ )
1654
1658
  .option("--json", "JSON output")
1655
1659
  .action(async (target: string, cmdOpts: Record<string, unknown>) => {
1656
1660
  const format = getFormat(cmdOpts);
@@ -1684,6 +1688,7 @@ function wirePublishCommand(program: Command): void {
1684
1688
  json: format === "json",
1685
1689
  out: typeof cmdOpts.out === "string" ? cmdOpts.out : undefined,
1686
1690
  encryptionPassphrase: passphrase,
1691
+ preview: cmdOpts.preview === true,
1687
1692
  slug: cmdOpts.slug as string | undefined,
1688
1693
  summary: cmdOpts.summary as string | undefined,
1689
1694
  title: cmdOpts.title as string | undefined,
@@ -15,6 +15,7 @@ import type {
15
15
  import {
16
16
  adapterError,
17
17
  corruptError,
18
+ permissionError,
18
19
  timeoutError,
19
20
  tooLargeError,
20
21
  } from "../../errors";
@@ -33,6 +34,15 @@ const SUPPORTED_MIMES = [
33
34
  "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
34
35
  ];
35
36
 
37
+ const PDF_SIGNATURE = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]);
38
+ const CFB_SIGNATURE = new Uint8Array([
39
+ 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1,
40
+ ]);
41
+ const ENCRYPTION_INFO = utf16le("EncryptionInfo");
42
+ const ENCRYPTED_PACKAGE = utf16le("EncryptedPackage");
43
+ const MAX_MESSAGE_LENGTH = 200;
44
+ const PASSWORD_ERROR_REGEX = /password(?:-protected)?|no password given/i;
45
+
36
46
  /**
37
47
  * Create zero-copy Buffer view of Uint8Array.
38
48
  * Assumes input.bytes is immutable (contract requirement).
@@ -41,6 +51,103 @@ function toBuffer(bytes: Uint8Array): Buffer {
41
51
  return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
42
52
  }
43
53
 
54
+ function utf16le(value: string): Uint8Array {
55
+ return new Uint8Array(Buffer.from(value, "utf16le"));
56
+ }
57
+
58
+ function hasPrefix(bytes: Uint8Array, prefix: Uint8Array): boolean {
59
+ if (bytes.length < prefix.length) {
60
+ return false;
61
+ }
62
+
63
+ for (let index = 0; index < prefix.length; index += 1) {
64
+ if (bytes[index] !== prefix[index]) {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ return true;
70
+ }
71
+
72
+ function includesBytes(bytes: Uint8Array, needle: Uint8Array): boolean {
73
+ if (needle.length === 0 || bytes.length < needle.length) {
74
+ return false;
75
+ }
76
+
77
+ outer: for (
78
+ let index = 0;
79
+ index <= bytes.length - needle.length;
80
+ index += 1
81
+ ) {
82
+ for (let offset = 0; offset < needle.length; offset += 1) {
83
+ if (bytes[index + offset] !== needle[offset]) {
84
+ continue outer;
85
+ }
86
+ }
87
+ return true;
88
+ }
89
+
90
+ return false;
91
+ }
92
+
93
+ function isPasswordProtectedPdf(bytes: Uint8Array): boolean {
94
+ if (!hasPrefix(bytes, PDF_SIGNATURE)) {
95
+ return false;
96
+ }
97
+
98
+ const tailStart = Math.max(0, bytes.length - 64 * 1024);
99
+ const tail = Buffer.from(bytes.subarray(tailStart)).toString("latin1");
100
+ return /\/Encrypt\b/.test(tail);
101
+ }
102
+
103
+ function isPasswordProtectedXlsx(bytes: Uint8Array): boolean {
104
+ return (
105
+ hasPrefix(bytes, CFB_SIGNATURE) &&
106
+ includesBytes(bytes, ENCRYPTION_INFO) &&
107
+ includesBytes(bytes, ENCRYPTED_PACKAGE)
108
+ );
109
+ }
110
+
111
+ function isPasswordProtected(input: ConvertInput): boolean {
112
+ if (input.ext === ".pdf") {
113
+ return isPasswordProtectedPdf(input.bytes);
114
+ }
115
+
116
+ if (input.ext === ".xlsx") {
117
+ return isPasswordProtectedXlsx(input.bytes);
118
+ }
119
+
120
+ return false;
121
+ }
122
+
123
+ function sanitizeErrorMessage(message: string, input: ConvertInput): string {
124
+ const normalized = message.trim();
125
+
126
+ let hasControlChars = false;
127
+ for (const char of normalized) {
128
+ const code = char.charCodeAt(0);
129
+ if (
130
+ (code >= 0 && code <= 8) ||
131
+ code === 11 ||
132
+ code === 12 ||
133
+ (code >= 14 && code <= 31)
134
+ ) {
135
+ hasControlChars = true;
136
+ break;
137
+ }
138
+ }
139
+
140
+ if (
141
+ normalized.length === 0 ||
142
+ normalized.length > MAX_MESSAGE_LENGTH ||
143
+ hasControlChars
144
+ ) {
145
+ return `Could not convert ${input.ext} file to markdown`;
146
+ }
147
+
148
+ return normalized;
149
+ }
150
+
44
151
  export const markitdownAdapter: Converter = {
45
152
  id: CONVERTER_ID,
46
153
  version: CONVERTER_VERSION,
@@ -55,6 +162,21 @@ export const markitdownAdapter: Converter = {
55
162
  return { ok: false, error: tooLargeError(input, CONVERTER_ID) };
56
163
  }
57
164
 
165
+ // 1b. Detect password-protected documents before calling markitdown-ts.
166
+ // markitdown-ts logs dependency stack traces to stderr for these files.
167
+ if (isPasswordProtected(input)) {
168
+ return {
169
+ ok: false,
170
+ error: permissionError(
171
+ input,
172
+ CONVERTER_ID,
173
+ "File is password-protected",
174
+ undefined,
175
+ { protection: input.ext.slice(1) }
176
+ ),
177
+ };
178
+ }
179
+
58
180
  // 2. Setup timeout handling
59
181
  // Note: markitdown-ts doesn't support AbortSignal, so underlying
60
182
  // work may continue after timeout (known limitation; process isolation future work)
@@ -128,14 +250,27 @@ export const markitdownAdapter: Converter = {
128
250
  }
129
251
 
130
252
  // Map adapter errors
253
+ const message =
254
+ err instanceof Error
255
+ ? sanitizeErrorMessage(err.message, input)
256
+ : `Could not convert ${input.ext} file to markdown`;
257
+
258
+ if (PASSWORD_ERROR_REGEX.test(message)) {
259
+ return {
260
+ ok: false,
261
+ error: permissionError(
262
+ input,
263
+ CONVERTER_ID,
264
+ "File is password-protected",
265
+ err,
266
+ { protection: input.ext.slice(1) }
267
+ ),
268
+ };
269
+ }
270
+
131
271
  return {
132
272
  ok: false,
133
- error: adapterError(
134
- input,
135
- CONVERTER_ID,
136
- err instanceof Error ? err.message : "Unknown error",
137
- err
138
- ),
273
+ error: adapterError(input, CONVERTER_ID, message, err),
139
274
  };
140
275
  }
141
276
  },
@@ -179,6 +179,29 @@ export function corruptError(
179
179
  });
180
180
  }
181
181
 
182
+ /**
183
+ * Create an error for permission-gated files (for example password-protected documents).
184
+ */
185
+ export function permissionError(
186
+ input: Pick<ConvertInput, "sourcePath" | "mime" | "ext">,
187
+ converterId: string,
188
+ message: string,
189
+ cause?: unknown,
190
+ details?: Record<string, unknown>
191
+ ): ConvertError {
192
+ return convertError("PERMISSION", {
193
+ message,
194
+ retryable: false,
195
+ fatal: false,
196
+ converterId,
197
+ sourcePath: input.sourcePath,
198
+ mime: input.mime,
199
+ ext: input.ext,
200
+ cause,
201
+ details,
202
+ });
203
+ }
204
+
182
205
  /**
183
206
  * Create an error for adapter-level failures.
184
207
  */
@@ -2,8 +2,6 @@ import { randomBytes, webcrypto } from "node:crypto";
2
2
 
3
3
  import type { EncryptedArtifactPayload, PublishArtifactNote } from "./artifact";
4
4
 
5
- import { slugify } from "./artifact";
6
-
7
5
  const { subtle } = webcrypto;
8
6
 
9
7
  const PBKDF2_ITERATIONS = 210_000;
@@ -19,6 +17,7 @@ type MetadataEntry = {
19
17
 
20
18
  type NoteBlock =
21
19
  | { type: "paragraph"; text: string }
20
+ | { type: "markdown"; markdown: string }
22
21
  | { type: "heading"; depth: 2 | 3; id: string; text: string }
23
22
  | { type: "list"; items: string[]; style: "ordered" | "unordered" }
24
23
  | { code: string; language: string; type: "code" }
@@ -108,120 +107,12 @@ const filterMetadata = (
108
107
  }));
109
108
  };
110
109
 
111
- const parseMarkdownBlocks = (markdown: string): NoteBlock[] => {
112
- const blocks: NoteBlock[] = [];
113
- const lines = stripFrontmatter(markdown).split("\n");
114
- let paragraph: string[] = [];
115
- let listItems: string[] = [];
116
- let codeFence: { code: string[]; language: string } | null = null;
117
-
118
- const flushParagraph = () => {
119
- if (paragraph.length === 0) {
120
- return;
121
- }
122
-
123
- blocks.push({
124
- type: "paragraph",
125
- text: paragraph.join(" ").trim(),
126
- });
127
- paragraph = [];
128
- };
129
-
130
- const flushList = () => {
131
- if (listItems.length === 0) {
132
- return;
133
- }
134
-
135
- blocks.push({
136
- type: "list",
137
- style: "unordered",
138
- items: listItems,
139
- });
140
- listItems = [];
141
- };
142
-
143
- for (const line of lines) {
144
- if (codeFence) {
145
- if (line.startsWith("```")) {
146
- blocks.push({
147
- type: "code",
148
- language: codeFence.language || "text",
149
- code: codeFence.code.join("\n"),
150
- });
151
- codeFence = null;
152
- continue;
153
- }
154
-
155
- codeFence.code.push(line);
156
- continue;
157
- }
158
-
159
- const trimmed = line.trim();
160
- if (!trimmed) {
161
- flushParagraph();
162
- flushList();
163
- continue;
164
- }
165
-
166
- const codeMatch = trimmed.match(/^```([\w-]*)$/);
167
- if (codeMatch) {
168
- flushParagraph();
169
- flushList();
170
- codeFence = { code: [], language: codeMatch[1] || "text" };
171
- continue;
172
- }
173
-
174
- const headingMatch = trimmed.match(/^(#{1,3})\s+(.*)$/);
175
- if (headingMatch) {
176
- flushParagraph();
177
- flushList();
178
- const headingText = headingMatch[2] ?? "";
179
- blocks.push({
180
- type: "heading",
181
- depth: headingMatch[1] === "###" ? 3 : 2,
182
- id: slugify(headingText),
183
- text: headingText,
184
- });
185
- continue;
186
- }
187
-
188
- const imageMatch = trimmed.match(/^!\[(.*?)\]\((.*?)\)$/);
189
- if (imageMatch) {
190
- flushParagraph();
191
- flushList();
192
- const alt = imageMatch[1] ?? "";
193
- const src = imageMatch[2] ?? "";
194
- blocks.push({
195
- type: "image",
196
- alt,
197
- src,
198
- caption: alt,
199
- });
200
- continue;
201
- }
202
-
203
- const listMatch = trimmed.match(/^[-*]\s+(.*)$/);
204
- if (listMatch) {
205
- flushParagraph();
206
- listItems.push(listMatch[1] ?? "");
207
- continue;
208
- }
209
-
210
- paragraph.push(trimmed);
211
- }
212
-
213
- flushParagraph();
214
- flushList();
215
-
216
- if (blocks.length === 0) {
217
- blocks.push({
218
- type: "paragraph",
219
- text: markdown.trim(),
220
- });
221
- }
222
-
223
- return blocks;
224
- };
110
+ const parseMarkdownBlocks = (markdown: string): NoteBlock[] => [
111
+ {
112
+ type: "markdown",
113
+ markdown: stripFrontmatter(markdown).trim(),
114
+ },
115
+ ];
225
116
 
226
117
  const getOutline = (blocks: NoteBlock[]) =>
227
118
  blocks.flatMap((block) =>
@@ -23,6 +23,11 @@ import {
23
23
  type PublishVisibility,
24
24
  } from "./artifact";
25
25
  import { buildEncryptedArtifactPayload } from "./encrypted-export";
26
+ import {
27
+ isPublishDisabledByFrontmatter,
28
+ sanitizeObsidianMarkdown,
29
+ type SanitizeWarning,
30
+ } from "./obsidian-sanitize";
26
31
 
27
32
  export interface PublishExportCoreOptions {
28
33
  encryptionPassphrase?: string;
@@ -129,7 +134,8 @@ async function exportCollectionArtifact(
129
134
  store: StorePort,
130
135
  collections: Collection[],
131
136
  target: string,
132
- options: PublishExportCoreOptions
137
+ options: PublishExportCoreOptions,
138
+ warnings: SanitizeWarning[]
133
139
  ) {
134
140
  const collection = resolveCollection(collections, target);
135
141
  if (!collection) {
@@ -151,7 +157,13 @@ async function exportCollectionArtifact(
151
157
 
152
158
  const notes: PublishArtifactNote[] = [];
153
159
  for (const doc of activeDocs) {
154
- const markdown = await loadDocumentMarkdown(store, doc);
160
+ const rawMarkdown = await loadDocumentMarkdown(store, doc);
161
+ if (isPublishDisabledByFrontmatter(rawMarkdown)) {
162
+ continue;
163
+ }
164
+ const sanitized = sanitizeObsidianMarkdown(rawMarkdown);
165
+ warnings.push(...sanitized.warnings);
166
+ const markdown = sanitized.markdown;
155
167
  const tags = await loadDocumentTags(store, doc);
156
168
  const frontmatter = parseFrontmatter(markdown).metadata;
157
169
  const title = deriveExportedTitle(doc);
@@ -164,6 +176,12 @@ async function exportCollectionArtifact(
164
176
  });
165
177
  }
166
178
 
179
+ if (notes.length === 0) {
180
+ throw new Error(
181
+ `Collection "${collection.name}" has no publishable documents (all notes carry publish: false frontmatter)`
182
+ );
183
+ }
184
+
167
185
  const title = options.title ?? collection.name;
168
186
  const summary =
169
187
  options.summary ??
@@ -217,14 +235,23 @@ async function exportCollectionArtifact(
217
235
  async function exportDocumentArtifact(
218
236
  store: StorePort,
219
237
  target: string,
220
- options: PublishExportCoreOptions
238
+ options: PublishExportCoreOptions,
239
+ warnings: SanitizeWarning[]
221
240
  ) {
222
241
  const doc = await lookupDocument(store, target);
223
242
  if (!doc?.active) {
224
243
  throw new Error(`Document not found: ${target}`);
225
244
  }
226
245
 
227
- const markdown = await loadDocumentMarkdown(store, doc);
246
+ const rawMarkdown = await loadDocumentMarkdown(store, doc);
247
+ if (isPublishDisabledByFrontmatter(rawMarkdown)) {
248
+ throw new Error(
249
+ `Refused to export: ${doc.uri} has publish: false in frontmatter`
250
+ );
251
+ }
252
+ const sanitized = sanitizeObsidianMarkdown(rawMarkdown);
253
+ warnings.push(...sanitized.warnings);
254
+ const markdown = sanitized.markdown;
228
255
  const tags = await loadDocumentTags(store, doc);
229
256
  const frontmatter = parseFrontmatter(markdown).metadata;
230
257
  const title = options.title ?? deriveExportedTitle(doc);
@@ -287,19 +314,31 @@ async function exportDocumentArtifact(
287
314
  });
288
315
  }
289
316
 
317
+ export interface ExportPublishArtifactResult {
318
+ artifact: PublishArtifact;
319
+ warnings: SanitizeWarning[];
320
+ }
321
+
290
322
  export async function exportPublishArtifact(input: {
291
323
  collections: Collection[];
292
324
  options: PublishExportCoreOptions;
293
325
  store: StorePort;
294
326
  target: string;
295
- }): Promise<PublishArtifact> {
296
- return (
327
+ }): Promise<ExportPublishArtifactResult> {
328
+ const warnings: SanitizeWarning[] = [];
329
+ const artifact =
297
330
  (await exportCollectionArtifact(
298
331
  input.store,
299
332
  input.collections,
300
333
  input.target,
301
- input.options
334
+ input.options,
335
+ warnings
302
336
  )) ??
303
- (await exportDocumentArtifact(input.store, input.target, input.options))
304
- );
337
+ (await exportDocumentArtifact(
338
+ input.store,
339
+ input.target,
340
+ input.options,
341
+ warnings
342
+ ));
343
+ return { artifact, warnings };
305
344
  }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Obsidian-aware markdown pre-processor for publish export.
3
+ *
4
+ * Strips wikilinks, drops navigation sidebar idioms, removes references to
5
+ * private (`_internal/`) paths, and warns on unresolvable image embeds before
6
+ * the markdown enters the publish artifact.
7
+ *
8
+ * @module src/publish/obsidian-sanitize
9
+ */
10
+
11
+ const WIKILINK_INTERNAL_PREFIX = /_internal\//i;
12
+ const NAV_SIDEBAR_LINE = /^(!?\[\[[^\]]+\]\]\s*\|?\s*)+$/;
13
+ const IMAGE_EMBED = /!\[\[([^\]]+)\]\]/g;
14
+ const INTERNAL_WIKILINK = /\[\[\s*_internal\/[^\]]+\]\]/gi;
15
+ const ALIASED_WIKILINK = /\[\[([^\]|]+)\|([^\]]+)\]\]/g;
16
+ const BARE_WIKILINK = /\[\[([^\]]+)\]\]/g;
17
+ const TAIL_SEGMENT = /[^/]+$/;
18
+ const BLOCK_ID_SUFFIX = /#\^?[\w-]+$/;
19
+
20
+ export interface SanitizeWarning {
21
+ kind:
22
+ | "image-embed-dropped"
23
+ | "internal-reference-stripped"
24
+ | "nav-sidebar-dropped"
25
+ | "wikilink-unresolved";
26
+ detail: string;
27
+ }
28
+
29
+ export interface SanitizeResult {
30
+ markdown: string;
31
+ warnings: SanitizeWarning[];
32
+ }
33
+
34
+ const splitFrontmatter = (
35
+ source: string
36
+ ): { body: string; frontmatter: string } => {
37
+ if (!source.startsWith("---\n") && !source.startsWith("---\r\n")) {
38
+ return { body: source, frontmatter: "" };
39
+ }
40
+ const endIndex = source.indexOf("\n---\n");
41
+ const endIndexCrlf = source.indexOf("\r\n---\r\n");
42
+ const terminators = [endIndex, endIndexCrlf].filter((index) => index !== -1);
43
+ if (terminators.length === 0) {
44
+ return { body: source, frontmatter: "" };
45
+ }
46
+ const earliest = Math.min(...terminators);
47
+ const matched =
48
+ earliest === endIndex
49
+ ? source.slice(0, earliest + 5)
50
+ : source.slice(0, earliest + 7);
51
+ return {
52
+ body: source.slice(matched.length),
53
+ frontmatter: matched,
54
+ };
55
+ };
56
+
57
+ const deriveLinkDisplay = (target: string): string => {
58
+ const raw = target.trim();
59
+ const withoutBlockId = raw.replace(BLOCK_ID_SUFFIX, "").trim();
60
+ const tail = withoutBlockId.match(TAIL_SEGMENT)?.[0] ?? withoutBlockId;
61
+ return tail.trim() || raw;
62
+ };
63
+
64
+ export function sanitizeObsidianMarkdown(source: string): SanitizeResult {
65
+ const { body, frontmatter } = splitFrontmatter(source);
66
+ const warnings: SanitizeWarning[] = [];
67
+ const lines = body.split("\n");
68
+ const output: string[] = [];
69
+ let seenContent = false;
70
+
71
+ for (const rawLine of lines) {
72
+ const rawTrimmed = rawLine.trim();
73
+
74
+ if (!seenContent && rawTrimmed && NAV_SIDEBAR_LINE.test(rawTrimmed)) {
75
+ warnings.push({
76
+ kind: "nav-sidebar-dropped",
77
+ detail: rawTrimmed,
78
+ });
79
+ continue;
80
+ }
81
+
82
+ let line = rawLine;
83
+
84
+ line = line.replace(IMAGE_EMBED, (_match, target: string) => {
85
+ warnings.push({
86
+ kind: "image-embed-dropped",
87
+ detail: target.trim(),
88
+ });
89
+ return "";
90
+ });
91
+
92
+ line = line.replace(INTERNAL_WIKILINK, (match) => {
93
+ warnings.push({
94
+ kind: "internal-reference-stripped",
95
+ detail: match,
96
+ });
97
+ return "";
98
+ });
99
+
100
+ line = line.replace(
101
+ ALIASED_WIKILINK,
102
+ (_match, target: string, alias: string) => {
103
+ if (WIKILINK_INTERNAL_PREFIX.test(target)) {
104
+ warnings.push({
105
+ kind: "internal-reference-stripped",
106
+ detail: target.trim(),
107
+ });
108
+ return "";
109
+ }
110
+ return alias.trim();
111
+ }
112
+ );
113
+
114
+ line = line.replace(BARE_WIKILINK, (_match, target: string) => {
115
+ if (WIKILINK_INTERNAL_PREFIX.test(target)) {
116
+ warnings.push({
117
+ kind: "internal-reference-stripped",
118
+ detail: target.trim(),
119
+ });
120
+ return "";
121
+ }
122
+ const display = deriveLinkDisplay(target);
123
+ warnings.push({
124
+ kind: "wikilink-unresolved",
125
+ detail: target.trim(),
126
+ });
127
+ return display;
128
+ });
129
+
130
+ if (line.trim()) {
131
+ seenContent = true;
132
+ }
133
+
134
+ output.push(line);
135
+ }
136
+
137
+ return {
138
+ markdown: `${frontmatter}${output.join("\n")}`,
139
+ warnings,
140
+ };
141
+ }
142
+
143
+ const FRONTMATTER_PUBLISH_FALSE = /^publish:\s*(false|no|0)\s*$/im;
144
+
145
+ export function isPublishDisabledByFrontmatter(source: string): boolean {
146
+ const { frontmatter } = splitFrontmatter(source);
147
+ if (!frontmatter) {
148
+ return false;
149
+ }
150
+ return FRONTMATTER_PUBLISH_FALSE.test(frontmatter);
151
+ }
152
+
153
+ export function formatSanitizeWarnings(warnings: SanitizeWarning[]): string[] {
154
+ const grouped = new Map<SanitizeWarning["kind"], Set<string>>();
155
+ for (const warning of warnings) {
156
+ const bucket = grouped.get(warning.kind) ?? new Set<string>();
157
+ bucket.add(warning.detail);
158
+ grouped.set(warning.kind, bucket);
159
+ }
160
+
161
+ const labels: Record<SanitizeWarning["kind"], string> = {
162
+ "image-embed-dropped": "Image embeds dropped (attachments not bundled yet)",
163
+ "internal-reference-stripped": "Private `_internal/` references stripped",
164
+ "nav-sidebar-dropped": "Navigation sidebar lines dropped",
165
+ "wikilink-unresolved":
166
+ "Wikilinks converted to plain text (no in-space target)",
167
+ };
168
+
169
+ return Array.from(grouped.entries()).flatMap(([kind, details]) => {
170
+ const lines = [`- ${labels[kind]}:`];
171
+ for (const detail of Array.from(details).sort()) {
172
+ lines.push(` • ${detail}`);
173
+ }
174
+ return lines;
175
+ });
176
+ }
@@ -783,7 +783,7 @@ export async function handlePublishExport(
783
783
  }
784
784
 
785
785
  try {
786
- const artifact = await exportPublishArtifact({
786
+ const { artifact, warnings } = await exportPublishArtifact({
787
787
  collections: config.collections,
788
788
  options: {
789
789
  encryptionPassphrase: body.encryptionPassphrase,
@@ -800,6 +800,7 @@ export async function handlePublishExport(
800
800
  artifact,
801
801
  fileName: derivePublishArtifactFilename(artifact),
802
802
  uploadUrl: "https://gno.sh/studio",
803
+ warnings,
803
804
  });
804
805
  } catch (error) {
805
806
  return errorResponse(