@gmickel/gno 0.41.1 → 1.0.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.
@@ -0,0 +1,297 @@
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 EncryptedArtifactPayload {
36
+ ciphertext: string;
37
+ iterations: number;
38
+ iv: string;
39
+ salt: string;
40
+ }
41
+
42
+ export interface EncryptedPublishArtifactSpace {
43
+ encryptedPayload: EncryptedArtifactPayload;
44
+ routeSlug: string;
45
+ secretToken: string;
46
+ sourceType: "note" | "collection";
47
+ visibility: "encrypted";
48
+ }
49
+
50
+ export interface PublishArtifactV1 {
51
+ exportedAt: string;
52
+ source: string;
53
+ spaces: PublishArtifactSpace[];
54
+ version: 1;
55
+ }
56
+
57
+ export interface PublishArtifactV2 {
58
+ exportedAt: string;
59
+ source: string;
60
+ spaces: EncryptedPublishArtifactSpace[];
61
+ version: 2;
62
+ }
63
+
64
+ export type PublishArtifact = PublishArtifactV1 | PublishArtifactV2;
65
+
66
+ export const PUBLISH_VISIBILITY_VALUES = [
67
+ "public",
68
+ "secret-link",
69
+ "invite-only",
70
+ "encrypted",
71
+ ] as const;
72
+
73
+ export const MAX_PUBLISH_SLUG_LENGTH = 80;
74
+
75
+ const ALLOWED_FRONTMATTER_METADATA_KEYS = new Set([
76
+ "audience",
77
+ "canonical",
78
+ "canonicalUrl",
79
+ "canonicalURL",
80
+ "coverAlt",
81
+ "coverImage",
82
+ "icon",
83
+ "image",
84
+ "layout",
85
+ "publishedAt",
86
+ "readingTime",
87
+ "series",
88
+ "seriesOrder",
89
+ "status",
90
+ "subtitle",
91
+ "theme",
92
+ "topic",
93
+ "topics",
94
+ ]);
95
+
96
+ export const isPublishVisibility = (
97
+ value: unknown
98
+ ): value is PublishVisibility =>
99
+ typeof value === "string" &&
100
+ PUBLISH_VISIBILITY_VALUES.includes(value as PublishVisibility);
101
+
102
+ export const slugify = (value: string) =>
103
+ value
104
+ .toLowerCase()
105
+ .replace(/[^a-z0-9]+/g, "-")
106
+ .replace(/(^-|-$)/g, "");
107
+
108
+ function toPublishSlugCandidate(value: string): string {
109
+ return slugify(value).slice(0, MAX_PUBLISH_SLUG_LENGTH).replace(/-+$/g, "");
110
+ }
111
+
112
+ export function derivePublishSlug(
113
+ candidates: Array<string>,
114
+ fallback = "untitled"
115
+ ): string {
116
+ for (const candidate of candidates) {
117
+ const slug = toPublishSlugCandidate(candidate);
118
+ if (slug.length > 0) {
119
+ return slug;
120
+ }
121
+ }
122
+
123
+ return fallback;
124
+ }
125
+
126
+ export const normalizePublishSlug = (value: string, fallback?: string) =>
127
+ derivePublishSlug([value], fallback);
128
+
129
+ const basenameWithoutExt = (value: string) =>
130
+ value
131
+ .split("/")
132
+ .pop()
133
+ ?.replace(/\.[^.]+$/, "") ?? value;
134
+
135
+ export const deriveExportedTitle = (
136
+ doc: Pick<DocumentRow, "relPath" | "title">
137
+ ) => doc.title?.trim() || basenameWithoutExt(doc.relPath);
138
+
139
+ export const deriveExportedSlug = (
140
+ doc: Pick<DocumentRow, "relPath" | "title">
141
+ ) =>
142
+ derivePublishSlug([
143
+ deriveExportedTitle(doc),
144
+ doc.relPath.replace(/\.[^.]+$/, "").replaceAll("/", "-"),
145
+ ]);
146
+
147
+ export const deriveExportedSummary = (
148
+ markdown: string,
149
+ metadata: Record<string, unknown>
150
+ ) => {
151
+ const metadataSummary =
152
+ typeof metadata.description === "string"
153
+ ? metadata.description
154
+ : typeof metadata.summary === "string"
155
+ ? metadata.summary
156
+ : null;
157
+ if (metadataSummary?.trim()) {
158
+ return metadataSummary.trim();
159
+ }
160
+
161
+ const plain = stripFrontmatter(markdown)
162
+ .split("\n")
163
+ .map((line) => line.trim())
164
+ .filter(
165
+ (line) =>
166
+ line.length > 0 &&
167
+ !line.startsWith("#") &&
168
+ !line.startsWith("!") &&
169
+ !line.startsWith("```")
170
+ )
171
+ .join(" ")
172
+ .replace(/\s+/g, " ")
173
+ .trim();
174
+
175
+ return plain.slice(0, 200).trim();
176
+ };
177
+
178
+ export const buildExportedMetadata = (
179
+ doc: Pick<
180
+ DocumentRow,
181
+ | "author"
182
+ | "categories"
183
+ | "collection"
184
+ | "contentType"
185
+ | "frontmatterDate"
186
+ | "languageHint"
187
+ | "relPath"
188
+ >,
189
+ parsedFrontmatter: Record<string, unknown>,
190
+ tags: TagRow[]
191
+ ) => {
192
+ const metadata: Record<string, string | string[]> = {};
193
+
194
+ if (doc.author) {
195
+ metadata.author = doc.author;
196
+ }
197
+ if (doc.contentType) {
198
+ metadata.contentType = doc.contentType;
199
+ }
200
+ if (doc.languageHint) {
201
+ metadata.language = doc.languageHint;
202
+ }
203
+ if (doc.frontmatterDate) {
204
+ metadata.date = doc.frontmatterDate;
205
+ }
206
+ if (doc.categories?.length) {
207
+ metadata.categories = doc.categories;
208
+ }
209
+
210
+ const tagValues = tags.map((tag) => tag.tag);
211
+ if (tagValues.length) {
212
+ metadata.tags = tagValues;
213
+ }
214
+
215
+ metadata.collection = doc.collection;
216
+ metadata.sourceRelPath = doc.relPath;
217
+
218
+ for (const [key, value] of Object.entries(parsedFrontmatter)) {
219
+ if (
220
+ key === "tags" ||
221
+ key === "title" ||
222
+ key === "summary" ||
223
+ !ALLOWED_FRONTMATTER_METADATA_KEYS.has(key)
224
+ ) {
225
+ continue;
226
+ }
227
+ if (typeof value === "string" && value.trim()) {
228
+ metadata[key] = value.trim();
229
+ continue;
230
+ }
231
+ if (Array.isArray(value)) {
232
+ const cleaned = value
233
+ .filter((entry): entry is string => typeof entry === "string")
234
+ .map((entry) => entry.trim())
235
+ .filter(Boolean);
236
+ if (cleaned.length > 0) {
237
+ metadata[key] = cleaned;
238
+ }
239
+ }
240
+ }
241
+
242
+ return metadata;
243
+ };
244
+
245
+ export const buildPublishArtifact = (input: {
246
+ homeNoteSlug?: string;
247
+ notes: PublishArtifactNote[];
248
+ routeSlug: string;
249
+ source: string;
250
+ sourceType: "note" | "collection";
251
+ summary: string;
252
+ title: string;
253
+ visibility: PublishVisibility;
254
+ }) => ({
255
+ exportedAt: new Date().toISOString(),
256
+ source: input.source,
257
+ spaces: [
258
+ {
259
+ homeNoteSlug: input.homeNoteSlug,
260
+ notes: input.notes,
261
+ routeSlug: input.routeSlug,
262
+ sourceType: input.sourceType,
263
+ summary: input.summary,
264
+ title: input.title,
265
+ visibility: input.visibility,
266
+ },
267
+ ],
268
+ version: 1 as const,
269
+ });
270
+
271
+ export const buildEncryptedPublishArtifact = (input: {
272
+ encryptedPayload: EncryptedArtifactPayload;
273
+ routeSlug: string;
274
+ secretToken: string;
275
+ source: string;
276
+ sourceType: "note" | "collection";
277
+ }) => ({
278
+ exportedAt: new Date().toISOString(),
279
+ source: input.source,
280
+ spaces: [
281
+ {
282
+ encryptedPayload: input.encryptedPayload,
283
+ routeSlug: input.routeSlug,
284
+ secretToken: input.secretToken,
285
+ sourceType: input.sourceType,
286
+ visibility: "encrypted" as const,
287
+ },
288
+ ],
289
+ version: 2 as const,
290
+ });
291
+
292
+ export const derivePublishArtifactFilename = (artifact: PublishArtifact) => {
293
+ const routeSlug =
294
+ artifact.spaces[0]?.routeSlug.trim() ||
295
+ normalizePublishSlug(artifact.source, "publish-artifact");
296
+ return `${routeSlug || "publish-artifact"}.json`;
297
+ };
@@ -0,0 +1,384 @@
1
+ import { randomBytes, webcrypto } from "node:crypto";
2
+
3
+ import type { EncryptedArtifactPayload, PublishArtifactNote } from "./artifact";
4
+
5
+ import { slugify } from "./artifact";
6
+
7
+ const { subtle } = webcrypto;
8
+
9
+ const PBKDF2_ITERATIONS = 210_000;
10
+ const IV_BYTES = 12;
11
+ const SALT_BYTES = 16;
12
+
13
+ const encoder = new TextEncoder();
14
+
15
+ type MetadataEntry = {
16
+ label: string;
17
+ value: string;
18
+ };
19
+
20
+ type NoteBlock =
21
+ | { type: "paragraph"; text: string }
22
+ | { type: "heading"; depth: 2 | 3; id: string; text: string }
23
+ | { type: "list"; items: string[]; style: "ordered" | "unordered" }
24
+ | { code: string; language: string; type: "code" }
25
+ | { alt: string; caption: string; src: string; type: "image" };
26
+
27
+ type ReaderNoteCard = {
28
+ backlinks: Array<{
29
+ excerpt: string;
30
+ noteId: string;
31
+ slug: string;
32
+ title: string;
33
+ }>;
34
+ blocks: NoteBlock[];
35
+ excerpt: string;
36
+ metadata: MetadataEntry[];
37
+ noteId: string;
38
+ outline: Array<{ depth: 2 | 3; id: string; text: string }>;
39
+ related: Array<{
40
+ excerpt: string;
41
+ noteId: string;
42
+ score: number;
43
+ slug: string;
44
+ title: string;
45
+ }>;
46
+ slug: string;
47
+ summary: string;
48
+ title: string;
49
+ };
50
+
51
+ type ReaderSpaceData = {
52
+ assetManifest: [];
53
+ currentNote: ReaderNoteCard;
54
+ homeNoteSlug?: string;
55
+ metadataPreview: MetadataEntry[];
56
+ nextNoteSlug?: string;
57
+ noteCards: ReaderNoteCard[];
58
+ previousNoteSlug?: string;
59
+ searchIndex: Array<{
60
+ excerpt: string;
61
+ haystack: string;
62
+ noteId: string;
63
+ slug: string;
64
+ title: string;
65
+ }>;
66
+ shareLabel: string;
67
+ sharePath: string;
68
+ snapshot: {
69
+ createdAt: string;
70
+ id: string;
71
+ lastIndexedAt: string;
72
+ searchEnabled: boolean;
73
+ version: number;
74
+ };
75
+ sourceType: "note" | "collection";
76
+ summary: string;
77
+ title: string;
78
+ visibility: "encrypted";
79
+ };
80
+
81
+ const toBase64 = (value: Uint8Array) => Buffer.from(value).toString("base64");
82
+
83
+ const stripFrontmatter = (markdown: string) => {
84
+ if (!markdown.startsWith("---\n")) {
85
+ return markdown;
86
+ }
87
+
88
+ const endIndex = markdown.indexOf("\n---\n");
89
+ if (endIndex === -1) {
90
+ return markdown;
91
+ }
92
+
93
+ return markdown.slice(endIndex + 5);
94
+ };
95
+
96
+ const filterMetadata = (
97
+ metadata?: Record<string, string | string[]>
98
+ ): MetadataEntry[] => {
99
+ if (!metadata) {
100
+ return [];
101
+ }
102
+
103
+ return Object.entries(metadata).map(([key, value]) => ({
104
+ label: key
105
+ .replace(/([A-Z])/g, " $1")
106
+ .replace(/^./, (char) => char.toUpperCase()),
107
+ value: Array.isArray(value) ? value.join(", ") : value,
108
+ }));
109
+ };
110
+
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
+ };
225
+
226
+ const getOutline = (blocks: NoteBlock[]) =>
227
+ blocks.flatMap((block) =>
228
+ block.type === "heading"
229
+ ? [{ depth: block.depth, id: block.id, text: block.text }]
230
+ : []
231
+ );
232
+
233
+ const makeToken = (slug: string) =>
234
+ `${slug}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
235
+
236
+ const deriveExcerpt = (summary: string, blocks: NoteBlock[]) => {
237
+ if (summary.trim()) {
238
+ return summary.trim();
239
+ }
240
+
241
+ const paragraph = blocks.find((block) => block.type === "paragraph");
242
+ return paragraph?.text.slice(0, 160) ?? "";
243
+ };
244
+
245
+ const deriveReaderPayload = (input: {
246
+ exportedAt: string;
247
+ homeNoteSlug?: string;
248
+ notes: PublishArtifactNote[];
249
+ routeSlug: string;
250
+ sourceType: "note" | "collection";
251
+ summary: string;
252
+ title: string;
253
+ }) => {
254
+ const noteCards: ReaderNoteCard[] = input.notes.map((note) => {
255
+ const blocks = parseMarkdownBlocks(note.markdown);
256
+ return {
257
+ noteId: `${input.routeSlug}:${note.slug}`,
258
+ slug: note.slug,
259
+ title: note.title,
260
+ excerpt: deriveExcerpt(note.summary, blocks),
261
+ summary: note.summary,
262
+ blocks,
263
+ metadata: filterMetadata(note.metadata),
264
+ outline: getOutline(blocks),
265
+ backlinks: [],
266
+ related: [],
267
+ };
268
+ });
269
+
270
+ const currentNote =
271
+ (input.homeNoteSlug
272
+ ? noteCards.find((note) => note.slug === input.homeNoteSlug)
273
+ : undefined) ?? noteCards[0];
274
+
275
+ if (!currentNote) {
276
+ throw new Error(
277
+ `Encrypted publish "${input.routeSlug}" requires at least one note`
278
+ );
279
+ }
280
+
281
+ const currentIndex = noteCards.findIndex(
282
+ (note) => note.noteId === currentNote.noteId
283
+ );
284
+ const sharePath = `/locked/${makeToken(input.routeSlug)}`;
285
+
286
+ return {
287
+ payload: {
288
+ sharePath,
289
+ shareLabel: "Encrypted share",
290
+ visibility: "encrypted" as const,
291
+ sourceType: input.sourceType,
292
+ title: input.title,
293
+ summary: input.summary,
294
+ snapshot: {
295
+ id: `snapshot-${input.routeSlug}-encrypted-v1`,
296
+ version: 1,
297
+ createdAt: input.exportedAt,
298
+ lastIndexedAt: input.exportedAt,
299
+ searchEnabled: noteCards.length > 1,
300
+ },
301
+ metadataPreview: [],
302
+ assetManifest: [],
303
+ searchIndex: noteCards.map((note) => ({
304
+ noteId: note.noteId,
305
+ slug: note.slug,
306
+ title: note.title,
307
+ excerpt: note.excerpt,
308
+ haystack: `${note.title} ${note.summary}`.toLowerCase(),
309
+ })),
310
+ noteCards,
311
+ currentNote,
312
+ previousNoteSlug: noteCards[currentIndex - 1]?.slug,
313
+ nextNoteSlug: noteCards[currentIndex + 1]?.slug,
314
+ homeNoteSlug: input.homeNoteSlug ?? noteCards[0]?.slug,
315
+ } satisfies ReaderSpaceData,
316
+ secretToken: sharePath.replace("/locked/", ""),
317
+ };
318
+ };
319
+
320
+ const deriveKey = async (passphrase: string, salt: Uint8Array) => {
321
+ const material = await subtle.importKey(
322
+ "raw",
323
+ encoder.encode(passphrase),
324
+ "PBKDF2",
325
+ false,
326
+ ["deriveKey"]
327
+ );
328
+
329
+ return subtle.deriveKey(
330
+ {
331
+ name: "PBKDF2",
332
+ hash: "SHA-256",
333
+ salt,
334
+ iterations: PBKDF2_ITERATIONS,
335
+ },
336
+ material,
337
+ {
338
+ name: "AES-GCM",
339
+ length: 256,
340
+ },
341
+ false,
342
+ ["encrypt"]
343
+ );
344
+ };
345
+
346
+ const encryptJson = async (
347
+ passphrase: string,
348
+ payload: unknown
349
+ ): Promise<EncryptedArtifactPayload> => {
350
+ const salt = randomBytes(SALT_BYTES);
351
+ const iv = randomBytes(IV_BYTES);
352
+ const key = await deriveKey(passphrase, salt);
353
+ const plaintext = encoder.encode(JSON.stringify(payload));
354
+ const ciphertext = await subtle.encrypt(
355
+ { name: "AES-GCM", iv },
356
+ key,
357
+ plaintext
358
+ );
359
+
360
+ return {
361
+ ciphertext: toBase64(new Uint8Array(ciphertext)),
362
+ iv: toBase64(iv),
363
+ salt: toBase64(salt),
364
+ iterations: PBKDF2_ITERATIONS,
365
+ };
366
+ };
367
+
368
+ export const buildEncryptedArtifactPayload = async (input: {
369
+ exportedAt: string;
370
+ homeNoteSlug?: string;
371
+ notes: PublishArtifactNote[];
372
+ passphrase: string;
373
+ routeSlug: string;
374
+ sourceType: "note" | "collection";
375
+ summary: string;
376
+ title: string;
377
+ }) => {
378
+ const { payload, secretToken } = deriveReaderPayload(input);
379
+
380
+ return {
381
+ encryptedPayload: await encryptJson(input.passphrase, payload),
382
+ secretToken,
383
+ };
384
+ };