@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.
- package/README.md +68 -1
- package/assets/screenshots/publish-reader.jpg +0 -0
- package/assets/skill/SKILL.md +2 -0
- package/assets/skill/cli-reference.md +28 -0
- package/package.json +1 -1
- package/src/cli/commands/index.ts +6 -0
- package/src/cli/commands/publish.ts +142 -0
- package/src/cli/options.ts +2 -0
- package/src/cli/program.ts +85 -0
- package/src/publish/artifact.ts +297 -0
- package/src/publish/encrypted-export.ts +384 -0
- package/src/publish/export-service.ts +305 -0
- package/src/serve/AGENTS.md +17 -16
- package/src/serve/CLAUDE.md +17 -16
- package/src/serve/public/lib/publish-export.ts +21 -0
- package/src/serve/public/pages/Collections.tsx +63 -0
- package/src/serve/public/pages/DocView.tsx +71 -0
- package/src/serve/routes/api.ts +90 -0
- package/src/serve/server.ts +12 -0
|
@@ -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
|
+
};
|