@gmickel/gno 1.21.0 → 1.22.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 +11 -2
- package/package.json +2 -1
- package/spec/cli.md +44 -0
- package/spec/evals-agentic.md +48 -0
- package/spec/output-schemas/publish-artifact.schema.json +284 -0
- package/src/publish/artifact-validation.ts +259 -0
- package/src/publish/artifact.ts +234 -118
- package/src/publish/export-service.ts +5 -9
- package/src/publish/metadata.ts +195 -0
|
@@ -8,7 +8,7 @@ import type { Collection } from "../config/types";
|
|
|
8
8
|
import type { DocumentRow, StorePort, TagRow } from "../store/types";
|
|
9
9
|
|
|
10
10
|
import { parseRef } from "../core/ref-parser";
|
|
11
|
-
import { parseFrontmatter } from "../ingestion/frontmatter";
|
|
11
|
+
import { parseFrontmatter, stripFrontmatter } from "../ingestion/frontmatter";
|
|
12
12
|
import { getContentBatch } from "../store/content-batch";
|
|
13
13
|
import {
|
|
14
14
|
buildEncryptedPublishArtifact,
|
|
@@ -186,11 +186,11 @@ async function exportCollectionArtifact(
|
|
|
186
186
|
if (isPublishDisabledByFrontmatter(rawMarkdown)) {
|
|
187
187
|
continue;
|
|
188
188
|
}
|
|
189
|
+
const frontmatter = parseFrontmatter(rawMarkdown).metadata;
|
|
189
190
|
const sanitized = sanitizeObsidianMarkdown(rawMarkdown);
|
|
190
191
|
warnings.push(...sanitized.warnings);
|
|
191
|
-
const markdown = sanitized.markdown;
|
|
192
|
+
const markdown = stripFrontmatter(sanitized.markdown);
|
|
192
193
|
const tags = tagsByDocId.get(doc.id) ?? [];
|
|
193
|
-
const frontmatter = parseFrontmatter(markdown).metadata;
|
|
194
194
|
const title = deriveExportedTitle(doc);
|
|
195
195
|
notes.push({
|
|
196
196
|
markdown,
|
|
@@ -240,7 +240,6 @@ async function exportCollectionArtifact(
|
|
|
240
240
|
encryptedPayload: encrypted.encryptedPayload,
|
|
241
241
|
routeSlug,
|
|
242
242
|
secretToken: encrypted.secretToken,
|
|
243
|
-
source: collection.name,
|
|
244
243
|
sourceType: "collection",
|
|
245
244
|
});
|
|
246
245
|
}
|
|
@@ -249,7 +248,6 @@ async function exportCollectionArtifact(
|
|
|
249
248
|
homeNoteSlug: chooseHomeNoteSlug(notes),
|
|
250
249
|
notes,
|
|
251
250
|
routeSlug,
|
|
252
|
-
source: collection.name,
|
|
253
251
|
sourceType: "collection",
|
|
254
252
|
summary,
|
|
255
253
|
title,
|
|
@@ -274,11 +272,11 @@ async function exportDocumentArtifact(
|
|
|
274
272
|
`Refused to export: ${doc.uri} has publish: false in frontmatter`
|
|
275
273
|
);
|
|
276
274
|
}
|
|
275
|
+
const frontmatter = parseFrontmatter(rawMarkdown).metadata;
|
|
277
276
|
const sanitized = sanitizeObsidianMarkdown(rawMarkdown);
|
|
278
277
|
warnings.push(...sanitized.warnings);
|
|
279
|
-
const markdown = sanitized.markdown;
|
|
278
|
+
const markdown = stripFrontmatter(sanitized.markdown);
|
|
280
279
|
const tags = await loadDocumentTags(store, doc);
|
|
281
|
-
const frontmatter = parseFrontmatter(markdown).metadata;
|
|
282
280
|
const title = options.title ?? deriveExportedTitle(doc);
|
|
283
281
|
const summary =
|
|
284
282
|
options.summary ?? deriveExportedSummary(markdown, frontmatter);
|
|
@@ -315,7 +313,6 @@ async function exportDocumentArtifact(
|
|
|
315
313
|
encryptedPayload: encrypted.encryptedPayload,
|
|
316
314
|
routeSlug,
|
|
317
315
|
secretToken: encrypted.secretToken,
|
|
318
|
-
source: doc.uri,
|
|
319
316
|
sourceType: "note",
|
|
320
317
|
});
|
|
321
318
|
}
|
|
@@ -331,7 +328,6 @@ async function exportDocumentArtifact(
|
|
|
331
328
|
},
|
|
332
329
|
],
|
|
333
330
|
routeSlug,
|
|
334
|
-
source: doc.uri,
|
|
335
331
|
sourceType: "note",
|
|
336
332
|
summary,
|
|
337
333
|
title,
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reader-safe publish metadata projection.
|
|
3
|
+
*
|
|
4
|
+
* @module src/publish/metadata
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { DocumentRow, TagRow } from "../store/types";
|
|
8
|
+
|
|
9
|
+
const ALLOWED_FRONTMATTER_METADATA_KEYS = new Set([
|
|
10
|
+
"audience",
|
|
11
|
+
"canonical",
|
|
12
|
+
"canonicalUrl",
|
|
13
|
+
"canonicalURL",
|
|
14
|
+
"coverAlt",
|
|
15
|
+
"coverImage",
|
|
16
|
+
"icon",
|
|
17
|
+
"image",
|
|
18
|
+
"layout",
|
|
19
|
+
"publishedAt",
|
|
20
|
+
"readingTime",
|
|
21
|
+
"series",
|
|
22
|
+
"seriesOrder",
|
|
23
|
+
"status",
|
|
24
|
+
"subtitle",
|
|
25
|
+
"theme",
|
|
26
|
+
"topic",
|
|
27
|
+
"topics",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const PUBLIC_URL_METADATA_KEYS = new Set([
|
|
31
|
+
"canonical",
|
|
32
|
+
"canonicalUrl",
|
|
33
|
+
"canonicalURL",
|
|
34
|
+
"coverImage",
|
|
35
|
+
"image",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const FORBIDDEN_URI_TOKEN_PATTERN =
|
|
39
|
+
/(?:^|[^a-z0-9+.-])(?:file:(?:\/\/)?|gno:\/\/)/iu;
|
|
40
|
+
const LOCAL_PATH_TOKEN_PATTERN =
|
|
41
|
+
/(?:^|[\s([{"'=,:;])(?:~[/\\]|[a-z]:[/\\]|\\\\[^\\/\s]+[/\\]|\/(?:Applications|bin|dev|etc|home|Library|mnt|opt|private|proc|root|srv|sys|System|tmp|Users|usr|var|Volumes)(?:[/\\]|$))/iu;
|
|
42
|
+
const LOCAL_HOSTNAME_SUFFIX_PATTERN =
|
|
43
|
+
/(?:^|\.)(?:home|internal|lan|local|localhost)$/iu;
|
|
44
|
+
|
|
45
|
+
const containsLocalReference = (value: string): boolean =>
|
|
46
|
+
FORBIDDEN_URI_TOKEN_PATTERN.test(value) ||
|
|
47
|
+
LOCAL_PATH_TOKEN_PATTERN.test(value);
|
|
48
|
+
|
|
49
|
+
const isNonPublicIpv4 = (hostname: string): boolean => {
|
|
50
|
+
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(hostname)) return false;
|
|
51
|
+
const octets = hostname.split(".").map(Number);
|
|
52
|
+
if (octets.some((octet) => octet > 255)) return true;
|
|
53
|
+
const [first = 0, second = 0, third = 0] = octets;
|
|
54
|
+
return (
|
|
55
|
+
first === 0 ||
|
|
56
|
+
first === 10 ||
|
|
57
|
+
first === 127 ||
|
|
58
|
+
first >= 224 ||
|
|
59
|
+
(first === 100 && second >= 64 && second <= 127) ||
|
|
60
|
+
(first === 169 && second === 254) ||
|
|
61
|
+
(first === 172 && second >= 16 && second <= 31) ||
|
|
62
|
+
(first === 192 && second === 0 && third === 0) ||
|
|
63
|
+
(first === 192 && second === 0 && third === 2) ||
|
|
64
|
+
(first === 192 && second === 168) ||
|
|
65
|
+
(first === 198 && (second === 18 || second === 19)) ||
|
|
66
|
+
(first === 198 && second === 51 && third === 100) ||
|
|
67
|
+
(first === 203 && second === 0 && third === 113)
|
|
68
|
+
);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const isNonPublicIpv6 = (hostname: string): boolean => {
|
|
72
|
+
const normalized = hostname.replace(/^\[|\]$/gu, "").toLowerCase();
|
|
73
|
+
if (!normalized.includes(":")) return false;
|
|
74
|
+
return (
|
|
75
|
+
normalized === "::" ||
|
|
76
|
+
normalized.startsWith("::") ||
|
|
77
|
+
normalized.startsWith("fc") ||
|
|
78
|
+
normalized.startsWith("fd") ||
|
|
79
|
+
/^fe[89ab]/u.test(normalized) ||
|
|
80
|
+
normalized.startsWith("ff") ||
|
|
81
|
+
normalized.startsWith("2001:db8:")
|
|
82
|
+
);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const isPublicHttpUrl = (value: string): boolean => {
|
|
86
|
+
let url: URL;
|
|
87
|
+
try {
|
|
88
|
+
url = new URL(value);
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (
|
|
94
|
+
(url.protocol !== "http:" && url.protocol !== "https:") ||
|
|
95
|
+
url.username.length > 0 ||
|
|
96
|
+
url.password.length > 0
|
|
97
|
+
) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const hostname = url.hostname.replace(/\.$/u, "").toLowerCase();
|
|
102
|
+
if (
|
|
103
|
+
hostname.length === 0 ||
|
|
104
|
+
LOCAL_HOSTNAME_SUFFIX_PATTERN.test(hostname) ||
|
|
105
|
+
isNonPublicIpv4(hostname) ||
|
|
106
|
+
isNonPublicIpv6(hostname)
|
|
107
|
+
) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const isIpLiteral =
|
|
112
|
+
hostname.includes(":") || /^\d+(?:\.\d+){3}$/u.test(hostname);
|
|
113
|
+
return isIpLiteral || hostname.includes(".");
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const isSafeMetadataValue = (key: string, value: string): boolean => {
|
|
117
|
+
if (containsLocalReference(value)) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
if (
|
|
121
|
+
PUBLIC_URL_METADATA_KEYS.has(key) ||
|
|
122
|
+
(key === "icon" && /^https?:\/\//iu.test(value))
|
|
123
|
+
) {
|
|
124
|
+
return isPublicHttpUrl(value);
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const filterReaderSafeMetadata = (
|
|
130
|
+
metadata: Record<string, string | string[]>
|
|
131
|
+
): Record<string, string | string[]> => {
|
|
132
|
+
const result: Record<string, string | string[]> = {};
|
|
133
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
134
|
+
if (typeof value === "string") {
|
|
135
|
+
if (isSafeMetadataValue(key, value)) {
|
|
136
|
+
result[key] = value;
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const safeValues = value.filter((entry) => isSafeMetadataValue(key, entry));
|
|
142
|
+
if (safeValues.length > 0) {
|
|
143
|
+
result[key] = safeValues;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
export const buildExportedMetadata = (
|
|
150
|
+
doc: Pick<
|
|
151
|
+
DocumentRow,
|
|
152
|
+
"author" | "categories" | "contentType" | "frontmatterDate" | "languageHint"
|
|
153
|
+
>,
|
|
154
|
+
parsedFrontmatter: Record<string, unknown>,
|
|
155
|
+
tags: TagRow[]
|
|
156
|
+
): Record<string, string | string[]> => {
|
|
157
|
+
const metadata: Record<string, string | string[]> = {};
|
|
158
|
+
|
|
159
|
+
if (doc.author) metadata.author = doc.author;
|
|
160
|
+
if (doc.contentType) metadata.contentType = doc.contentType;
|
|
161
|
+
if (doc.languageHint) metadata.language = doc.languageHint;
|
|
162
|
+
if (doc.frontmatterDate) metadata.date = doc.frontmatterDate;
|
|
163
|
+
if (doc.categories?.length) metadata.categories = doc.categories;
|
|
164
|
+
|
|
165
|
+
const tagValues = tags.map((tag) => tag.tag);
|
|
166
|
+
if (tagValues.length) metadata.tags = tagValues;
|
|
167
|
+
|
|
168
|
+
for (const [key, value] of Object.entries(parsedFrontmatter)) {
|
|
169
|
+
if (
|
|
170
|
+
key === "tags" ||
|
|
171
|
+
key === "title" ||
|
|
172
|
+
key === "summary" ||
|
|
173
|
+
!ALLOWED_FRONTMATTER_METADATA_KEYS.has(key)
|
|
174
|
+
) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (
|
|
178
|
+
typeof value === "string" &&
|
|
179
|
+
value.trim() &&
|
|
180
|
+
isSafeMetadataValue(key, value.trim())
|
|
181
|
+
) {
|
|
182
|
+
metadata[key] = value.trim();
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (Array.isArray(value)) {
|
|
186
|
+
const cleaned = value
|
|
187
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
188
|
+
.map((entry) => entry.trim())
|
|
189
|
+
.filter((entry) => entry.length > 0 && isSafeMetadataValue(key, entry));
|
|
190
|
+
if (cleaned.length > 0) metadata[key] = cleaned;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return filterReaderSafeMetadata(metadata);
|
|
195
|
+
};
|