@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.
@@ -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
+ };