@gmickel/gno 1.0.1 → 1.0.4
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/assets/skill/README.md +130 -0
- package/assets/skill/cli-reference.md +35 -8
- package/package.json +1 -1
- package/src/cli/commands/publish.ts +50 -2
- package/src/cli/program.ts +5 -0
- package/src/publish/export-service.ts +48 -9
- package/src/publish/obsidian-sanitize.ts +176 -0
- package/src/serve/routes/api.ts +2 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# GNO — Agent Skill
|
|
2
|
+
|
|
3
|
+
Local-first semantic search for documents, notes, and knowledge bases,
|
|
4
|
+
packaged as a Claude Code / Codex / OpenCode / OpenClaw skill.
|
|
5
|
+
|
|
6
|
+
> **TL;DR** — this folder is a runnable agent skill. Drop it into any
|
|
7
|
+
> Claude Code, Codex, OpenCode, or OpenClaw workspace and the agent can
|
|
8
|
+
> index and search your local files through the `gno` CLI.
|
|
9
|
+
|
|
10
|
+
## Prerequisites
|
|
11
|
+
|
|
12
|
+
You need the `gno` CLI installed locally. The skill drives it; it does
|
|
13
|
+
not ship the binary itself.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# macOS / Linux
|
|
17
|
+
curl -fsSL https://gno.sh/install | bash
|
|
18
|
+
|
|
19
|
+
# npm / Bun
|
|
20
|
+
bun add -g @gmickel/gno # or: npm install -g @gmickel/gno
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Verify: `gno --version` should print `1.0.3` or later.
|
|
24
|
+
|
|
25
|
+
## Install the skill
|
|
26
|
+
|
|
27
|
+
There are two ways to install, and which one you pick depends on where
|
|
28
|
+
you're starting from.
|
|
29
|
+
|
|
30
|
+
### Option A — install from your local `gno` (recommended if you already have GNO)
|
|
31
|
+
|
|
32
|
+
If you already installed GNO, it ships this skill in-tree. One command
|
|
33
|
+
drops it into the right place for every supported agent, always in sync
|
|
34
|
+
with your installed GNO version:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
gno skill install --target claude # Claude Code (default)
|
|
38
|
+
gno skill install --target codex # OpenAI Codex CLI
|
|
39
|
+
gno skill install --target opencode # OpenCode
|
|
40
|
+
gno skill install --target openclaw # OpenClaw
|
|
41
|
+
gno skill install --target all # All four
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Scope is configurable:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
gno skill install --scope project # ./.claude/skills/gno (default)
|
|
48
|
+
gno skill install --scope user # ~/.claude/skills/gno
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Inspect or uninstall:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
gno skill show # preview what would be installed
|
|
55
|
+
gno skill paths # resolved installation paths
|
|
56
|
+
gno skill uninstall --target all # remove from all agents
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
This path always gives you the skill that matches your GNO CLI — no
|
|
60
|
+
version drift.
|
|
61
|
+
|
|
62
|
+
### Option B — install from ClawHub (recommended if you use OpenClaw)
|
|
63
|
+
|
|
64
|
+
ClawHub is the OpenClaw skill registry. Use this when you manage
|
|
65
|
+
skills centrally across multiple workspaces or when you don't have GNO
|
|
66
|
+
installed yet.
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
openclaw skills install gno # installs @gmickel/gno
|
|
70
|
+
openclaw skills update gno # pull a newer version later
|
|
71
|
+
openclaw skills info gno # inspect what's installed
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
You can also browse the skill at
|
|
75
|
+
<https://clawhub.ai/gmickel/gno> and download the folder manually.
|
|
76
|
+
|
|
77
|
+
## What the skill teaches the agent
|
|
78
|
+
|
|
79
|
+
Once installed, the agent gains a single callable capability: run `gno`
|
|
80
|
+
commands from the workspace. The skill docs (see [SKILL.md](SKILL.md))
|
|
81
|
+
walk the model through:
|
|
82
|
+
|
|
83
|
+
- initialising a new index and adding collections
|
|
84
|
+
- keyword (`search`), vector (`vsearch`), hybrid (`query`) and
|
|
85
|
+
AI-answer (`ask`) search modes
|
|
86
|
+
- document retrieval (`get`, `multi-get`) including line ranges
|
|
87
|
+
- link graph traversal (`links`, `backlinks`, `similar`, `graph`)
|
|
88
|
+
- tagging, contexts, and per-collection embedding models
|
|
89
|
+
- publishing notes as gno.sh reader snapshots (`publish export`)
|
|
90
|
+
- MCP server setup for persistent agent access
|
|
91
|
+
|
|
92
|
+
The reference docs in this folder are discoverable by the agent via
|
|
93
|
+
progressive disclosure — the model only pulls them when it needs them:
|
|
94
|
+
|
|
95
|
+
- [SKILL.md](SKILL.md) — core instructions + frontmatter
|
|
96
|
+
- [cli-reference.md](cli-reference.md) — every CLI command, option and flag
|
|
97
|
+
- [mcp-reference.md](mcp-reference.md) — MCP tool and resource contract
|
|
98
|
+
- [examples.md](examples.md) — end-to-end usage patterns
|
|
99
|
+
|
|
100
|
+
## Agent tooling contract
|
|
101
|
+
|
|
102
|
+
The skill requests two tools:
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
105
|
+
allowed-tools: Bash(gno:*) Read
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- **`Bash(gno:*)`** — the agent can invoke `gno <anything>` on the
|
|
109
|
+
host. All writes are explicit CLI calls; nothing runs implicitly.
|
|
110
|
+
- **`Read`** — the agent can open files the user has pointed at. No
|
|
111
|
+
filesystem writes come through the skill itself.
|
|
112
|
+
|
|
113
|
+
## Versioning
|
|
114
|
+
|
|
115
|
+
The skill version is tracked separately from the GNO CLI version. When
|
|
116
|
+
the CLI ships a new feature worth teaching the model (new flag, new
|
|
117
|
+
subcommand, new sanitizer behaviour), the skill gets a point bump. See
|
|
118
|
+
the CHANGELOG entry on ClawHub or the `gno` repo's CHANGELOG for what
|
|
119
|
+
landed in each bump.
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT-0 — use it freely, with or without attribution.
|
|
124
|
+
|
|
125
|
+
## Links
|
|
126
|
+
|
|
127
|
+
- Source: <https://github.com/gmickel/gno>
|
|
128
|
+
- Hosted publishing: <https://gno.sh>
|
|
129
|
+
- OpenClaw docs: <https://docs.openclaw.ai>
|
|
130
|
+
- ClawHub listing: <https://clawhub.ai/gmickel/gno>
|
|
@@ -504,24 +504,51 @@ Export a note or collection as a gno.sh publish artifact JSON.
|
|
|
504
504
|
gno publish export <target> [--out <path.json>] [options]
|
|
505
505
|
```
|
|
506
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
|
-
| `--
|
|
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
|
+
| `--passphrase` | none | Required for `--visibility encrypted`; encrypts locally before upload |
|
|
515
|
+
| `--preview` | false | Print sanitized markdown + preprocessor report instead of writing artifact |
|
|
516
|
+
| `--json` | false | Structured result output |
|
|
515
517
|
|
|
516
518
|
Examples:
|
|
517
519
|
|
|
518
520
|
```bash
|
|
519
521
|
gno publish export work-docs --out ~/Downloads/work-docs.json
|
|
520
522
|
gno publish export "gno://work-docs/runbooks/deploy.md" --out ~/Downloads/deploy.json
|
|
523
|
+
|
|
524
|
+
# Encrypted share — ciphertext produced locally before upload
|
|
525
|
+
gno publish export "gno://work-docs/offer-letter.md" \
|
|
526
|
+
--visibility encrypted --passphrase "correct horse battery staple"
|
|
527
|
+
|
|
528
|
+
# Inspect what the sanitizer strips before writing anything
|
|
529
|
+
gno publish export "gno://vault/my-note.md" --preview
|
|
521
530
|
```
|
|
522
531
|
|
|
523
532
|
On success, upload the JSON file at `https://gno.sh/studio`.
|
|
524
533
|
|
|
534
|
+
**Obsidian pre-processor (v1.0.2+)**: before the artifact is written, the
|
|
535
|
+
export pipeline runs a sanitizer over each note's markdown. It:
|
|
536
|
+
|
|
537
|
+
- drops the navigation-sidebar idiom (`[[Hub]] | [[Related]]` immediately
|
|
538
|
+
under the frontmatter)
|
|
539
|
+
- strips any `[[_internal/...]]` references (privacy guard — the
|
|
540
|
+
`_internal/` convention is treated as never-publish)
|
|
541
|
+
- converts `[[Target|Alias]]` to the alias text, and `[[Target]]` to the
|
|
542
|
+
tail segment of the target
|
|
543
|
+
- drops `![[image.png]]` embeds (attachments are not bundled yet) with a
|
|
544
|
+
warning so the author can migrate to `` or wait for bundling
|
|
545
|
+
- refuses to export a note whose frontmatter contains `publish: false`
|
|
546
|
+
(single-note export errors; collection export silently skips)
|
|
547
|
+
|
|
548
|
+
Every sanitizer decision surfaces in the CLI output as a "Preprocessor
|
|
549
|
+
notes" section, on the `--json` response under `warnings`, and on
|
|
550
|
+
`--preview` as a structured report — so nothing is silently lost.
|
|
551
|
+
|
|
525
552
|
## Skill Management
|
|
526
553
|
|
|
527
554
|
### gno skill install
|
package/package.json
CHANGED
|
@@ -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 } =
|
|
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
|
}
|
package/src/cli/program.ts
CHANGED
|
@@ -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,
|
|
@@ -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
|
|
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
|
|
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<
|
|
296
|
-
|
|
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(
|
|
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
|
+
}
|
package/src/serve/routes/api.ts
CHANGED
|
@@ -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(
|