@andespindola/brainlink 1.0.2 → 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.
@@ -0,0 +1,220 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename, extname } from 'node:path';
3
+ import { ensureVault } from '../infrastructure/file-system-vault.js';
4
+ import { searchKnowledge } from './search-knowledge.js';
5
+ import { getGraphSummary } from './get-graph-summary.js';
6
+ import { getBrokenLinksReport } from './analyze-vault.js';
7
+ const stopwords = new Set([
8
+ 'about',
9
+ 'after',
10
+ 'agent',
11
+ 'agents',
12
+ 'antes',
13
+ 'brainlink',
14
+ 'com',
15
+ 'como',
16
+ 'das',
17
+ 'dos',
18
+ 'for',
19
+ 'from',
20
+ 'para',
21
+ 'por',
22
+ 'que',
23
+ 'the',
24
+ 'this',
25
+ 'uma',
26
+ 'with'
27
+ ]);
28
+ const wordPattern = /[\p{L}\p{N}_-]+/gu;
29
+ const tagPattern = /(^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)/g;
30
+ const unique = (values) => Array.from(new Set(values));
31
+ const normalizeText = (value) => value
32
+ .normalize('NFKD')
33
+ .replace(/\p{Diacritic}/gu, '')
34
+ .toLowerCase();
35
+ const words = (value) => normalizeText(value)
36
+ .match(wordPattern)
37
+ ?.filter((word) => word.length > 2 && !stopwords.has(word)) ?? [];
38
+ const titleCase = (value) => value
39
+ .split(/\s+/)
40
+ .filter(Boolean)
41
+ .map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`)
42
+ .join(' ');
43
+ const stripMarkdownNoise = (value) => value
44
+ .replace(/^---[\s\S]*?\n---\n?/, '')
45
+ .replace(/```[\s\S]*?```/g, ' ')
46
+ .replace(/[#>*_\-[\]()`]/g, ' ')
47
+ .replace(/\s+/g, ' ')
48
+ .trim();
49
+ export const inferMemoryTitle = (content, fallback = 'Memory Note') => {
50
+ const heading = content.match(/^#{1,6}\s+(.+)$/m)?.[1]?.trim();
51
+ if (heading)
52
+ return heading.replace(/\s+#\w[\w-]*/g, '').trim();
53
+ const firstSentence = stripMarkdownNoise(content).split(/[.!?\n]/)[0]?.trim();
54
+ if (!firstSentence)
55
+ return fallback;
56
+ return titleCase(firstSentence.split(/\s+/).slice(0, 8).join(' '));
57
+ };
58
+ export const inferMemoryTags = (content, extraTags = []) => {
59
+ const explicit = Array.from(content.matchAll(tagPattern), (match) => match[2]);
60
+ const weighted = words(content).reduce((state, word) => {
61
+ state.set(word, (state.get(word) ?? 0) + 1);
62
+ return state;
63
+ }, new Map());
64
+ const inferred = Array.from(weighted.entries())
65
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
66
+ .map(([word]) => word.replace(/[^a-z0-9_-]/g, '-'))
67
+ .filter((word) => word.length >= 3)
68
+ .slice(0, 4);
69
+ return unique([...extraTags, ...explicit, ...inferred])
70
+ .map((tag) => tag.replace(/^#/, '').trim())
71
+ .filter(Boolean)
72
+ .slice(0, 8);
73
+ };
74
+ const scoreTitleAgainstText = (title, content) => {
75
+ const textWords = new Set(words(content));
76
+ const titleWords = words(title);
77
+ if (titleWords.length === 0)
78
+ return 0;
79
+ const hits = titleWords.filter((word) => textWords.has(word)).length;
80
+ return Number((hits / titleWords.length).toFixed(4));
81
+ };
82
+ const editDistance = (left, right) => {
83
+ const previous = Array.from({ length: right.length + 1 }, (_, index) => index);
84
+ const current = Array.from({ length: right.length + 1 }, () => 0);
85
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
86
+ current[0] = leftIndex;
87
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
88
+ const cost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
89
+ current[rightIndex] = Math.min(previous[rightIndex] + 1, current[rightIndex - 1] + 1, previous[rightIndex - 1] + cost);
90
+ }
91
+ for (let index = 0; index < previous.length; index += 1) {
92
+ previous[index] = current[index];
93
+ }
94
+ }
95
+ return previous[right.length] ?? 0;
96
+ };
97
+ const titleSimilarity = (left, right) => {
98
+ const normalizedLeft = normalizeText(left).replace(/\s+/g, ' ').trim();
99
+ const normalizedRight = normalizeText(right).replace(/\s+/g, ' ').trim();
100
+ if (!normalizedLeft || !normalizedRight)
101
+ return 0;
102
+ if (normalizedLeft === normalizedRight)
103
+ return 1;
104
+ const distance = editDistance(normalizedLeft, normalizedRight);
105
+ const maxLength = Math.max(normalizedLeft.length, normalizedRight.length);
106
+ return Number(Math.max(0, 1 - distance / maxLength).toFixed(4));
107
+ };
108
+ export const suggestContextLinks = async (vaultPath, content, agentId, limit) => {
109
+ await ensureVault(vaultPath);
110
+ const graph = await getGraphSummary(vaultPath, agentId);
111
+ const byTitle = new Map();
112
+ for (const node of graph.nodes) {
113
+ const score = scoreTitleAgainstText(node.title, content);
114
+ if (score > 0) {
115
+ byTitle.set(node.title.toLowerCase(), {
116
+ title: node.title,
117
+ path: node.path,
118
+ score,
119
+ reason: 'title words appear in the new memory'
120
+ });
121
+ }
122
+ }
123
+ const query = stripMarkdownNoise(content).slice(0, 500);
124
+ const searchResults = query.length > 0
125
+ ? await searchKnowledge(vaultPath, query, Math.max(limit * 2, 6), agentId, 'hybrid')
126
+ : [];
127
+ for (const result of searchResults) {
128
+ const key = result.title.toLowerCase();
129
+ const current = byTitle.get(key);
130
+ const score = Number(Math.min(1, Math.max(result.score / 20, result.semanticScore)).toFixed(4));
131
+ if (!current || score > current.score) {
132
+ byTitle.set(key, {
133
+ title: result.title,
134
+ path: result.path,
135
+ score,
136
+ reason: result.textScore > 0 ? 'search matched title, tags or content' : 'semantic search found related content'
137
+ });
138
+ }
139
+ }
140
+ return Array.from(byTitle.values())
141
+ .sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
142
+ .slice(0, Math.max(0, limit));
143
+ };
144
+ export const buildRememberSuggestion = async (input) => {
145
+ const title = input.title?.trim() || inferMemoryTitle(input.content);
146
+ const tags = inferMemoryTags(input.content, input.tags ?? []);
147
+ const suggestedLinks = await suggestContextLinks(input.vaultPath, input.content, input.agentId, input.linkLimit ?? 5);
148
+ const explicitLinks = (input.links ?? []).map((link) => ({
149
+ title: link,
150
+ path: '',
151
+ score: 1,
152
+ reason: 'provided explicitly'
153
+ }));
154
+ const links = unique([...explicitLinks, ...suggestedLinks].map((link) => link.title))
155
+ .map((linkTitle) => explicitLinks.find((link) => link.title === linkTitle) ?? suggestedLinks.find((link) => link.title === linkTitle))
156
+ .filter((link) => Boolean(link));
157
+ const tagLine = tags.length > 0 ? `\n\n${tags.map((tag) => `#${tag}`).join(' ')}` : '';
158
+ const linkSection = links.length > 0
159
+ ? `\n\n## Context Links\n\n${links.map((link) => `- [[${link.title}]]`).join('\n')}`
160
+ : '';
161
+ return {
162
+ title,
163
+ tags,
164
+ links,
165
+ content: `${input.content.trim()}${tagLine}${linkSection}`.trim()
166
+ };
167
+ };
168
+ export const suggestBrokenLinkFixes = async (vaultPath, agentId, limit) => {
169
+ const [brokenLinks, graph] = await Promise.all([
170
+ getBrokenLinksReport(vaultPath, agentId),
171
+ getGraphSummary(vaultPath, agentId)
172
+ ]);
173
+ return brokenLinks.map((link) => ({
174
+ ...link,
175
+ candidates: graph.nodes
176
+ .map((node) => ({
177
+ title: node.title,
178
+ path: node.path,
179
+ score: Math.max(scoreTitleAgainstText(link.toTitle, `${node.title} ${node.path}`), titleSimilarity(link.toTitle, node.title)),
180
+ reason: 'similar title/path'
181
+ }))
182
+ .filter((candidate) => candidate.score > 0)
183
+ .sort((left, right) => right.score - left.score || left.title.localeCompare(right.title))
184
+ .slice(0, Math.max(0, limit))
185
+ }));
186
+ };
187
+ export const explainSearchResults = async (vaultPath, query, limit, agentId, mode) => {
188
+ const queryWords = new Set(words(query));
189
+ const results = await searchKnowledge(vaultPath, query, limit, agentId, mode);
190
+ return results.map((result) => {
191
+ const titleWords = words(result.title).filter((word) => queryWords.has(word));
192
+ const tagWords = result.tags.filter((tag) => queryWords.has(normalizeText(tag)));
193
+ const contentWords = words(result.content).filter((word) => queryWords.has(word));
194
+ const reasons = [
195
+ ...(titleWords.length > 0 ? [`title matched: ${unique(titleWords).join(', ')}`] : []),
196
+ ...(tagWords.length > 0 ? [`tags matched: ${unique(tagWords).join(', ')}`] : []),
197
+ ...(contentWords.length > 0 ? [`content matched: ${unique(contentWords).slice(0, 8).join(', ')}`] : []),
198
+ ...(result.semanticScore > 0 ? [`semantic score: ${result.semanticScore.toFixed(3)}`] : []),
199
+ `text score: ${result.textScore.toFixed(3)}`,
200
+ `final score: ${result.score.toFixed(3)}`
201
+ ];
202
+ return {
203
+ ...result,
204
+ reasons
205
+ };
206
+ });
207
+ };
208
+ export const readMemoryContentInput = async (input) => {
209
+ if (input.content != null && input.content.trim().length > 0) {
210
+ return input.content;
211
+ }
212
+ if (input.contentFile != null && input.contentFile.trim().length > 0) {
213
+ return readFile(input.contentFile, 'utf8');
214
+ }
215
+ throw new Error('Use --content or --content-file to provide content.');
216
+ };
217
+ export const inferTitleFromFilePath = (filePath) => basename(filePath, extname(filePath))
218
+ .replace(/[-_]+/g, ' ')
219
+ .replace(/\s+/g, ' ')
220
+ .trim();
@@ -0,0 +1,153 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { access, readFile } from 'node:fs/promises';
3
+ import { basename, join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { addNoteWithMetadata } from './add-note.js';
6
+ import { doctorVault, getStats, validateVault } from './analyze-vault.js';
7
+ import { indexVault } from './index-vault.js';
8
+ import { buildRememberSuggestion } from './memory-suggestions.js';
9
+ import { ensureVault } from '../infrastructure/file-system-vault.js';
10
+ const execFileAsync = promisify(execFile);
11
+ const readIfExists = async (path) => {
12
+ try {
13
+ await access(path);
14
+ return readFile(path, 'utf8');
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ };
20
+ const tryGitStatus = async (cwd) => {
21
+ try {
22
+ const { stdout } = await execFileAsync('git', ['status', '--short'], { cwd });
23
+ return stdout.trim();
24
+ }
25
+ catch {
26
+ return '';
27
+ }
28
+ };
29
+ export const buildActionableDoctor = async (vaultPath) => {
30
+ const [doctor, validation] = await Promise.all([
31
+ doctorVault(vaultPath),
32
+ validateVault(vaultPath)
33
+ ]);
34
+ const actions = [
35
+ ...(doctor.recommendations ?? []).map((recommendation) => ({
36
+ severity: 'warning',
37
+ command: recommendation.startsWith('Vault is empty') ? recommendation.replace(/^Vault is empty\. Add your first note: /, '') : recommendation,
38
+ reason: 'doctor recommendation'
39
+ })),
40
+ ...(validation.brokenLinks.length > 0
41
+ ? [{
42
+ severity: 'warning',
43
+ command: `blink repair-links --vault "${vaultPath}"`,
44
+ reason: `${validation.brokenLinks.length} unresolved wiki links can be safely repaired or materialized as placeholder targets`
45
+ }]
46
+ : []),
47
+ ...(validation.orphans.length > 0
48
+ ? [{
49
+ severity: 'info',
50
+ command: `blink suggest-links --vault "${vaultPath}"`,
51
+ reason: `${validation.orphans.length} notes have no incoming or outgoing links`
52
+ }]
53
+ : []),
54
+ {
55
+ severity: doctor.ok ? 'info' : 'critical',
56
+ command: `blink index --vault "${vaultPath}"`,
57
+ reason: 'keep derived index synchronized with Markdown source'
58
+ }
59
+ ];
60
+ return {
61
+ doctor,
62
+ validation,
63
+ actions
64
+ };
65
+ };
66
+ export const closeSession = async (input) => {
67
+ const [stats, validation, gitStatus] = await Promise.all([
68
+ getStats(input.vaultPath, input.agentId),
69
+ validateVault(input.vaultPath, input.agentId),
70
+ tryGitStatus(input.cwd)
71
+ ]);
72
+ const date = new Date().toISOString().slice(0, 10);
73
+ const title = `Session Close ${basename(input.cwd)} ${date}`;
74
+ const content = [
75
+ input.content?.trim() ? `## Notes\n\n${input.content.trim()}` : null,
76
+ '## Vault State',
77
+ `- Documents: ${stats.documentCount}`,
78
+ `- Links: ${stats.linkCount}`,
79
+ `- Broken links: ${stats.brokenLinkCount}`,
80
+ `- Orphans: ${stats.orphanCount}`,
81
+ gitStatus ? `\n## Git Status\n\n\`\`\`txt\n${gitStatus}\n\`\`\`` : null,
82
+ validation.brokenLinks.length > 0
83
+ ? `\n## Follow Up\n\n- Review unresolved links with \`blink suggest-links --broken\`.`
84
+ : null,
85
+ '\n#session #handoff'
86
+ ].filter((part) => Boolean(part)).join('\n');
87
+ const suggestion = await buildRememberSuggestion({
88
+ vaultPath: input.vaultPath,
89
+ content,
90
+ agentId: input.agentId,
91
+ title,
92
+ tags: ['session', 'handoff'],
93
+ linkLimit: 5
94
+ });
95
+ if (input.write === false) {
96
+ return {
97
+ title,
98
+ content: suggestion.content,
99
+ writtenPath: null,
100
+ index: null
101
+ };
102
+ }
103
+ const note = await addNoteWithMetadata(input.vaultPath, title, suggestion.content, input.agentId, {
104
+ autoContextLinks: false
105
+ });
106
+ const index = input.autoIndex === false ? null : await indexVault(input.vaultPath);
107
+ return {
108
+ title,
109
+ content: suggestion.content,
110
+ writtenPath: note.path,
111
+ index
112
+ };
113
+ };
114
+ export const initializeProjectMemory = async (input) => {
115
+ const vault = await ensureVault(input.vaultPath);
116
+ const candidates = ['AGENTS.md', 'README.md', 'docs/ARCHITECTURE.md', 'docs/QUICKSTART.md'];
117
+ const contents = await Promise.all(candidates.map(async (relativePath) => ({
118
+ relativePath,
119
+ content: await readIfExists(join(input.projectPath, relativePath))
120
+ })));
121
+ const available = contents.filter((item) => item.content !== null);
122
+ const fallback = available.length > 0
123
+ ? []
124
+ : [{
125
+ relativePath: 'Project Overview',
126
+ content: `Project path: ${input.projectPath}\n\n#project #memory`
127
+ }];
128
+ const notes = await Promise.all([...available, ...fallback].map(async (item) => {
129
+ const title = `Project ${basename(input.projectPath)} ${item.relativePath.replace(/\.md$/i, '').replace(/[\\/]/g, ' ')}`;
130
+ const suggestion = await buildRememberSuggestion({
131
+ vaultPath: input.vaultPath,
132
+ content: `${item.content.trim()}\n\n#project #memory`,
133
+ agentId: input.agentId,
134
+ title,
135
+ tags: ['project', 'memory'],
136
+ linkLimit: 5
137
+ });
138
+ const note = await addNoteWithMetadata(input.vaultPath, title, suggestion.content, input.agentId, {
139
+ autoContextLinks: false
140
+ });
141
+ return {
142
+ title,
143
+ path: note.path
144
+ };
145
+ }));
146
+ const index = await indexVault(input.vaultPath);
147
+ return {
148
+ projectPath: input.projectPath,
149
+ vault,
150
+ notes,
151
+ index
152
+ };
153
+ };
@@ -0,0 +1,157 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { sanitizeAgentId, sharedAgentId } from '../domain/agents.js';
4
+ import { parseMarkdownDocument } from '../domain/markdown.js';
5
+ import { ensureVault, readMarkdownFileSummaries, writeMarkdownFile } from '../infrastructure/file-system-vault.js';
6
+ import { indexVault } from './index-vault.js';
7
+ import { suggestBrokenLinkFixes } from './memory-suggestions.js';
8
+ const slugify = (title) => title
9
+ .normalize('NFKD')
10
+ .replace(/[\u0300-\u036f]/g, '')
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, '-')
13
+ .replace(/^-+|-+$/g, '') || 'untitled';
14
+ const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
15
+ const replaceWikiLinkTitle = (content, fromTitle, toTitle) => {
16
+ const pattern = new RegExp(`\\[\\[${escapeRegExp(fromTitle)}((?:#[^\\]|]+)?(?:\\|[^\\]]+)?)\\]\\]`, 'g');
17
+ return content.replace(pattern, `[[${toTitle}$1]]`);
18
+ };
19
+ const buildPlaceholderNote = (title, sources, agentId) => [
20
+ '---',
21
+ `title: "${title.replaceAll('"', '\\"')}"`,
22
+ `agent: "${sanitizeAgentId(agentId)}"`,
23
+ '---',
24
+ '',
25
+ `# ${title}`,
26
+ '',
27
+ 'Auto-created target for unresolved Brainlink wiki links. Replace this placeholder with durable context when the concept is clarified. #triage',
28
+ '',
29
+ '## Referenced By',
30
+ '',
31
+ ...sources.map((source) => `- [[${source}]]`),
32
+ ''
33
+ ].join('\n');
34
+ const readNotes = async (vaultPath) => {
35
+ const absoluteVaultPath = await ensureVault(vaultPath);
36
+ const summaries = await readMarkdownFileSummaries(absoluteVaultPath);
37
+ return Promise.all(summaries.map(async (summary) => {
38
+ const content = await readFile(summary.absolutePath, 'utf8');
39
+ const document = parseMarkdownDocument({
40
+ absolutePath: summary.absolutePath,
41
+ vaultPath: absoluteVaultPath,
42
+ content,
43
+ createdAt: summary.createdAt,
44
+ updatedAt: summary.updatedAt
45
+ });
46
+ return {
47
+ relativePath: summary.relativePath,
48
+ title: document.title,
49
+ agentId: document.agentId,
50
+ content
51
+ };
52
+ }));
53
+ };
54
+ const isConfident = (candidates, minScore, margin) => {
55
+ const [best, second] = candidates;
56
+ if (!best || best.score < minScore)
57
+ return false;
58
+ if (!second)
59
+ return true;
60
+ return best.score - second.score >= margin;
61
+ };
62
+ export const repairBrokenLinks = async (vaultPath, options = {}) => {
63
+ const minScore = options.minScore ?? 0.88;
64
+ const margin = options.margin ?? 0.12;
65
+ const createMissing = options.createMissing !== false;
66
+ const suggestions = await suggestBrokenLinkFixes(vaultPath, options.agentId, 5);
67
+ const notes = await readNotes(vaultPath);
68
+ const notesByPath = new Map(notes.map((note) => [note.relativePath, note]));
69
+ const nextContentByPath = new Map();
70
+ const placeholderSources = new Map();
71
+ const entries = [];
72
+ for (const brokenLink of suggestions) {
73
+ const candidates = brokenLink.candidates;
74
+ const best = candidates[0];
75
+ const source = notesByPath.get(brokenLink.fromPath);
76
+ if (best && isConfident(candidates, minScore, margin)) {
77
+ const currentContent = nextContentByPath.get(brokenLink.fromPath) ?? source?.content;
78
+ if (!source || currentContent == null) {
79
+ entries.push({
80
+ ...brokenLink,
81
+ action: 'skipped',
82
+ targetTitle: null,
83
+ targetPath: null,
84
+ score: best.score,
85
+ reason: 'source note was not found'
86
+ });
87
+ continue;
88
+ }
89
+ const nextContent = replaceWikiLinkTitle(currentContent, brokenLink.toTitle, best.title);
90
+ if (nextContent !== currentContent) {
91
+ nextContentByPath.set(brokenLink.fromPath, nextContent);
92
+ }
93
+ entries.push({
94
+ ...brokenLink,
95
+ action: 'retargeted',
96
+ targetTitle: best.title,
97
+ targetPath: best.path,
98
+ score: best.score,
99
+ reason: 'single high-confidence existing title match'
100
+ });
101
+ continue;
102
+ }
103
+ if (createMissing) {
104
+ const sourceTitles = placeholderSources.get(brokenLink.toTitle) ?? new Set();
105
+ sourceTitles.add(brokenLink.fromTitle);
106
+ placeholderSources.set(brokenLink.toTitle, sourceTitles);
107
+ entries.push({
108
+ ...brokenLink,
109
+ action: 'created-target',
110
+ targetTitle: brokenLink.toTitle,
111
+ targetPath: null,
112
+ score: best?.score ?? null,
113
+ reason: best ? 'no unambiguous existing target; placeholder target will be created' : 'no existing target candidate; placeholder target will be created'
114
+ });
115
+ continue;
116
+ }
117
+ entries.push({
118
+ ...brokenLink,
119
+ action: 'skipped',
120
+ targetTitle: null,
121
+ targetPath: null,
122
+ score: best?.score ?? null,
123
+ reason: best ? 'candidate score or margin was too low' : 'no candidate found'
124
+ });
125
+ }
126
+ if (!options.dryRun) {
127
+ await Promise.all(Array.from(nextContentByPath.entries()).map(([relativePath, content]) => writeMarkdownFile(vaultPath, relativePath, content)));
128
+ await Promise.all(Array.from(placeholderSources.entries()).map(([title, sources]) => {
129
+ const agentId = sanitizeAgentId(options.agentId ?? sharedAgentId);
130
+ const path = join('agents', agentId, 'unresolved-links', `${slugify(title)}.md`).replaceAll('\\', '/');
131
+ return writeMarkdownFile(vaultPath, path, buildPlaceholderNote(title, Array.from(sources), agentId));
132
+ }));
133
+ }
134
+ const changed = nextContentByPath.size;
135
+ const created = placeholderSources.size;
136
+ const index = !options.dryRun && options.autoIndex !== false && (changed > 0 || created > 0)
137
+ ? await indexVault(vaultPath)
138
+ : undefined;
139
+ return {
140
+ dryRun: options.dryRun === true,
141
+ scanned: suggestions.length,
142
+ changed,
143
+ created,
144
+ skipped: entries.filter((entry) => entry.action === 'skipped').length,
145
+ entries: entries.map((entry) => {
146
+ if (entry.action !== 'created-target' || entry.targetPath) {
147
+ return entry;
148
+ }
149
+ const agentId = sanitizeAgentId(options.agentId ?? sharedAgentId);
150
+ return {
151
+ ...entry,
152
+ targetPath: join('agents', agentId, 'unresolved-links', `${slugify(entry.toTitle)}.md`).replaceAll('\\', '/')
153
+ };
154
+ }),
155
+ ...(index ? { index } : {})
156
+ };
157
+ };
@@ -0,0 +1,80 @@
1
+ const readRequestBuffer = async (request, limitBytes) => {
2
+ const chunks = [];
3
+ let size = 0;
4
+ for await (const chunk of request) {
5
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
6
+ size += buffer.byteLength;
7
+ if (size > limitBytes) {
8
+ throw Object.assign(new Error('Request body too large'), { statusCode: 413 });
9
+ }
10
+ chunks.push(buffer);
11
+ }
12
+ return Buffer.concat(chunks);
13
+ };
14
+ const parseDisposition = (value) => value
15
+ .split(';')
16
+ .slice(1)
17
+ .reduce((params, entry) => {
18
+ const [rawKey, ...rawValue] = entry.trim().split('=');
19
+ const key = rawKey?.trim();
20
+ if (!key) {
21
+ return params;
22
+ }
23
+ params[key] = rawValue.join('=').trim().replace(/^"|"$/g, '');
24
+ return params;
25
+ }, {});
26
+ const parseHeaders = (value) => value.split('\r\n').reduce((headers, line) => {
27
+ const separator = line.indexOf(':');
28
+ if (separator < 0) {
29
+ return headers;
30
+ }
31
+ const key = line.slice(0, separator).trim().toLowerCase();
32
+ const headerValue = line.slice(separator + 1).trim();
33
+ if (key) {
34
+ headers[key] = headerValue;
35
+ }
36
+ return headers;
37
+ }, {});
38
+ const stripPartTerminator = (value) => value.endsWith('\r\n') ? value.slice(0, -2) : value;
39
+ export const parseMultipartForm = async (request, limitBytes = 50 * 1024 * 1024) => {
40
+ const contentType = Array.isArray(request.headers['content-type'])
41
+ ? request.headers['content-type'][0]
42
+ : request.headers['content-type'];
43
+ const boundary = contentType?.match(/boundary=(?:"([^"]+)"|([^;]+))/)?.[1] ?? contentType?.match(/boundary=(?:"([^"]+)"|([^;]+))/)?.[2];
44
+ if (!boundary) {
45
+ throw Object.assign(new Error('Missing multipart boundary'), { statusCode: 400 });
46
+ }
47
+ const body = await readRequestBuffer(request, limitBytes);
48
+ const bodyText = body.toString('binary');
49
+ const parts = bodyText.split(`--${boundary}`);
50
+ const fields = {};
51
+ const files = [];
52
+ for (const rawPart of parts) {
53
+ if (!rawPart || rawPart === '--\r\n' || rawPart === '--') {
54
+ continue;
55
+ }
56
+ const part = rawPart.startsWith('\r\n') ? rawPart.slice(2) : rawPart;
57
+ const separator = part.indexOf('\r\n\r\n');
58
+ if (separator < 0) {
59
+ continue;
60
+ }
61
+ const headers = parseHeaders(part.slice(0, separator));
62
+ const content = stripPartTerminator(part.slice(separator + 4));
63
+ const disposition = parseDisposition(headers['content-disposition'] ?? '');
64
+ const fieldName = disposition.name ?? '';
65
+ if (!fieldName) {
66
+ continue;
67
+ }
68
+ if (disposition.filename != null && disposition.filename.length > 0) {
69
+ files.push({
70
+ fieldName,
71
+ filename: disposition.filename,
72
+ contentType: headers['content-type'] ?? 'application/octet-stream',
73
+ data: Buffer.from(content, 'binary')
74
+ });
75
+ continue;
76
+ }
77
+ fields[fieldName] = Buffer.from(content, 'binary').toString('utf8');
78
+ }
79
+ return { fields, files };
80
+ };