@gpdoc/filekit 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/package.json +22 -0
- package/src/index.js +378 -0
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gpdoc/filekit",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Headless GPDoc file format detection, validation, and text conversion",
|
|
6
|
+
"repository": "https://github.com/repetere/gpdoc.git",
|
|
7
|
+
"files": [
|
|
8
|
+
"src"
|
|
9
|
+
],
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"exports": "./src/index.js",
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"fflate": "^0.8.2",
|
|
19
|
+
"js-yaml": "^4.1.0",
|
|
20
|
+
"marked": "^12.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
import { marked } from 'marked';
|
|
6
|
+
|
|
7
|
+
const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown', '.mdown', '.gpdoc.md']);
|
|
8
|
+
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
|
9
|
+
const TEXT_EXTENSIONS = new Set(['.txt', '.text']);
|
|
10
|
+
const UNSUPPORTED_BINARY_EXTENSIONS = new Set(['.pdf', '.rtf']);
|
|
11
|
+
const VALID_FILETYPES = new Set(['document', 'notebook', 'slides', 'script', 'spreadsheet', 'drawing']);
|
|
12
|
+
const FRONT_MATTER = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
13
|
+
const MAX_URL_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
14
|
+
const URL_TIMEOUT_MS = 15_000;
|
|
15
|
+
|
|
16
|
+
export class FileKitError extends Error {
|
|
17
|
+
constructor(code, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.code = code;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isPlainObject(value) {
|
|
24
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
25
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isSafeJsonValue(value, depth = 0) {
|
|
29
|
+
if (depth > 40) return false;
|
|
30
|
+
if (value === null || ['string', 'boolean'].includes(typeof value)) return true;
|
|
31
|
+
if (typeof value === 'number') return Number.isFinite(value);
|
|
32
|
+
if (Array.isArray(value)) return value.length <= 10_000 && value.every((entry) => isSafeJsonValue(entry, depth + 1));
|
|
33
|
+
if (!isPlainObject(value)) return false;
|
|
34
|
+
return Object.entries(value).every(([key, entry]) => !['__proto__', 'prototype', 'constructor'].includes(key)
|
|
35
|
+
&& isSafeJsonValue(entry, depth + 1));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function clone(value) {
|
|
39
|
+
return JSON.parse(JSON.stringify(value));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isManagedMetadata(value) {
|
|
43
|
+
return isPlainObject(value)
|
|
44
|
+
&& typeof value.id === 'string' && value.id.length > 0
|
|
45
|
+
&& typeof value.filename === 'string' && value.filename.length > 0
|
|
46
|
+
&& typeof value.filetype === 'string' && VALID_FILETYPES.has(value.filetype)
|
|
47
|
+
&& isSafeJsonValue(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function filenameTitle(filePath) {
|
|
51
|
+
const name = path.basename(filePath || 'Untitled').replace(/(?:\.gpdoc)?\.[^.]+$/i, '');
|
|
52
|
+
return name || 'Untitled';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function decodeEntities(value) {
|
|
56
|
+
return String(value)
|
|
57
|
+
.replace(/&#(x?[0-9a-f]+);/gi, (_match, encoded) => String.fromCodePoint(Number.parseInt(encoded, encoded[0].toLowerCase() === 'x' ? 16 : 10)))
|
|
58
|
+
.replace(/&(nbsp|amp|lt|gt|quot|apos|#39);/gi, (_match, name) => ({
|
|
59
|
+
nbsp: ' ', amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'",
|
|
60
|
+
})[name.toLowerCase()] || _match);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeBytes(input) {
|
|
64
|
+
if (input instanceof Uint8Array) return input;
|
|
65
|
+
if (input instanceof ArrayBuffer) return new Uint8Array(input);
|
|
66
|
+
if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength);
|
|
67
|
+
throw new FileKitError('INVALID_BINARY', 'Expected binary file data.');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function decodeXml(value) {
|
|
71
|
+
return decodeEntities(String(value)
|
|
72
|
+
.replace(/"/gi, '"')
|
|
73
|
+
.replace(/'/gi, "'"));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function escapeXml(value) {
|
|
77
|
+
return String(value).replace(/[&<>"']/g, (character) => ({
|
|
78
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
79
|
+
})[character]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function extractDocxMarkdown(input) {
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = unzipSync(normalizeBytes(input));
|
|
86
|
+
} catch {
|
|
87
|
+
throw new FileKitError('INVALID_BINARY', 'DOCX input is not a readable OpenXML package.');
|
|
88
|
+
}
|
|
89
|
+
const documentXml = entries['word/document.xml'];
|
|
90
|
+
if (!documentXml) throw new FileKitError('INVALID_BINARY', 'DOCX input does not contain word/document.xml.');
|
|
91
|
+
const paragraphs = strFromU8(documentXml).match(/<w:p(?:\s[^>]*)?>[\s\S]*?<\/w:p>/gi) || [];
|
|
92
|
+
return paragraphs.map((paragraph) => {
|
|
93
|
+
const style = paragraph.match(/<w:pStyle[^>]*w:val="([^"]+)"/i)?.[1] || '';
|
|
94
|
+
const text = decodeXml(paragraph
|
|
95
|
+
.replace(/<w:tab\s*\/?>/gi, '\t')
|
|
96
|
+
.replace(/<w:br\s*\/?>/gi, '\n')
|
|
97
|
+
.match(/<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>/gi)?.map((part) => part.replace(/^<w:t(?:\s[^>]*)?>|<\/w:t>$/gi, '')).join('') || '')
|
|
98
|
+
.replace(/\s+\n/g, '\n').trim();
|
|
99
|
+
if (!text) return '';
|
|
100
|
+
const heading = style.match(/heading\s*([1-6])/i)?.[1];
|
|
101
|
+
return heading ? `${'#'.repeat(Number(heading))} ${text}` : text;
|
|
102
|
+
}).filter(Boolean).join('\n\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function extractPptxMarkdown(input) {
|
|
106
|
+
let entries;
|
|
107
|
+
try {
|
|
108
|
+
entries = unzipSync(normalizeBytes(input));
|
|
109
|
+
} catch {
|
|
110
|
+
throw new FileKitError('INVALID_BINARY', 'PPTX input is not a readable OpenXML package.');
|
|
111
|
+
}
|
|
112
|
+
const slidePaths = Object.keys(entries).filter((entry) => /^ppt\/slides\/slide\d+\.xml$/i.test(entry))
|
|
113
|
+
.sort((a, b) => Number(a.match(/slide(\d+)\.xml/i)?.[1]) - Number(b.match(/slide(\d+)\.xml/i)?.[1]));
|
|
114
|
+
if (!slidePaths.length) throw new FileKitError('INVALID_BINARY', 'PPTX input does not contain slides.');
|
|
115
|
+
return slidePaths.map((slidePath, index) => {
|
|
116
|
+
const xml = strFromU8(entries[slidePath]);
|
|
117
|
+
const lines = (xml.match(/<a:p(?:\s[^>]*)?>[\s\S]*?<\/a:p>/gi) || []).map((paragraph) =>
|
|
118
|
+
decodeXml((paragraph.match(/<a:t(?:\s[^>]*)?>([\s\S]*?)<\/a:t>/gi) || [])
|
|
119
|
+
.map((part) => part.replace(/^<a:t(?:\s[^>]*)?>|<\/a:t>$/gi, '')).join('')).trim()).filter(Boolean);
|
|
120
|
+
const [title = `Slide ${index + 1}`, ...body] = lines;
|
|
121
|
+
return `# ${title.replace(/^#{1,6}\s+/, '')}${body.length ? `\n\n${body.join('\n\n')}` : ''}`;
|
|
122
|
+
}).join('\n\n---\n\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function createDocx(markdown, title) {
|
|
126
|
+
const paragraphs = String(markdown).split(/\n{2,}/).map((paragraph) => paragraph.trim()).filter(Boolean);
|
|
127
|
+
const body = paragraphs.map((paragraph) => {
|
|
128
|
+
const heading = paragraph.match(/^(#{1,6})\s+([\s\S]*)$/);
|
|
129
|
+
const text = markdownToText(heading ? heading[2] : paragraph).replace(/\n/g, ' ');
|
|
130
|
+
const style = heading ? `<w:pPr><w:pStyle w:val="Heading${heading[1].length}"/></w:pPr>` : '';
|
|
131
|
+
return `<w:p>${style}<w:r><w:t xml:space="preserve">${escapeXml(text)}</w:t></w:r></w:p>`;
|
|
132
|
+
}).join('');
|
|
133
|
+
const contentTypes = `<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`;
|
|
134
|
+
const relationships = `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`;
|
|
135
|
+
const document = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body || `<w:p><w:r><w:t>${escapeXml(title)}</w:t></w:r></w:p>`}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>`;
|
|
136
|
+
return zipSync({ '[Content_Types].xml': strToU8(contentTypes), '_rels/.rels': strToU8(relationships), 'word/document.xml': strToU8(document) }, { level: 0 });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function createPdf(markdown, title) {
|
|
140
|
+
const lines = [title, ...markdownToText(markdown).split('\n').filter(Boolean)].flatMap((line) => line.match(/.{1,90}(?:\s|$)|\S+/g) || ['']);
|
|
141
|
+
const operations = lines.slice(0, 42).map((line, index) => `${index === 0 ? '72 740 Td' : '0 -16 Td'} (${String(line).replace(/[\\()]/g, '\\$&')}) Tj`).join('\n');
|
|
142
|
+
const stream = `BT\n/F1 12 Tf\n${operations}\nET`;
|
|
143
|
+
const objects = [
|
|
144
|
+
'<< /Type /Catalog /Pages 2 0 R >>',
|
|
145
|
+
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
|
|
146
|
+
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>',
|
|
147
|
+
`<< /Length ${Buffer.byteLength(stream, 'utf8')} >>\nstream\n${stream}\nendstream`,
|
|
148
|
+
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
|
|
149
|
+
];
|
|
150
|
+
let output = '%PDF-1.4\n';
|
|
151
|
+
const offsets = [0];
|
|
152
|
+
objects.forEach((object, index) => { offsets.push(Buffer.byteLength(output, 'utf8')); output += `${index + 1} 0 obj\n${object}\nendobj\n`; });
|
|
153
|
+
const xref = Buffer.byteLength(output, 'utf8');
|
|
154
|
+
output += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.slice(1).map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`).join('')}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
|
155
|
+
return Buffer.from(output, 'utf8');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function inlineHtmlToMarkdown(value) {
|
|
159
|
+
let markdown = String(value);
|
|
160
|
+
markdown = markdown.replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (_match, href, label) => `[${inlineHtmlToMarkdown(label).trim() || href}](${decodeEntities(href)})`);
|
|
161
|
+
markdown = markdown.replace(/<(?:strong|b)\b[^>]*>([\s\S]*?)<\/(?:strong|b)>/gi, '**$1**');
|
|
162
|
+
markdown = markdown.replace(/<(?:em|i)\b[^>]*>([\s\S]*?)<\/(?:em|i)>/gi, '*$1*');
|
|
163
|
+
markdown = markdown.replace(/<(?:del|s|strike)\b[^>]*>([\s\S]*?)<\/(?:del|s|strike)>/gi, '~~$1~~');
|
|
164
|
+
markdown = markdown.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, '`$1`');
|
|
165
|
+
return decodeEntities(markdown.replace(/<[^>]+>/g, ''));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function htmlToMarkdown(html) {
|
|
169
|
+
let markdown = String(html || '').replace(/\r\n/g, '\n');
|
|
170
|
+
markdown = markdown.replace(/<!--[\s\S]*?-->/g, '');
|
|
171
|
+
markdown = markdown.replace(/<\s*(script|style|noscript)\b[^>]*>[\s\S]*?<\s*\/\s*\1>/gi, '');
|
|
172
|
+
const body = markdown.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
|
|
173
|
+
if (body) markdown = body[1];
|
|
174
|
+
else markdown = markdown
|
|
175
|
+
.replace(/<head\b[^>]*>[\s\S]*?<\/head>/gi, '')
|
|
176
|
+
.replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, '');
|
|
177
|
+
markdown = markdown.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_match, level, content) => `${'#'.repeat(Number(level))} ${inlineHtmlToMarkdown(content).trim()}\n\n`);
|
|
178
|
+
markdown = markdown.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, (_match, content) => `- ${inlineHtmlToMarkdown(content).trim()}\n`);
|
|
179
|
+
markdown = markdown.replace(/<\/?(?:ul|ol)\b[^>]*>/gi, '\n');
|
|
180
|
+
markdown = markdown.replace(/<p\b[^>]*>([\s\S]*?)<\/p>/gi, (_match, content) => `${inlineHtmlToMarkdown(content).trim()}\n\n`);
|
|
181
|
+
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
|
|
182
|
+
markdown = inlineHtmlToMarkdown(markdown);
|
|
183
|
+
return markdown.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function htmlTitle(html, fallback) {
|
|
187
|
+
const title = String(html).match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1];
|
|
188
|
+
return title ? decodeEntities(title.replace(/<[^>]+>/g, '')).trim() || fallback : fallback;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function readUrlDocument(input) {
|
|
192
|
+
let url;
|
|
193
|
+
try {
|
|
194
|
+
url = new URL(input);
|
|
195
|
+
} catch {
|
|
196
|
+
throw new FileKitError('INVALID_URL', 'URL input must be a valid HTTP or HTTPS URL.');
|
|
197
|
+
}
|
|
198
|
+
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
199
|
+
throw new FileKitError('INVALID_URL', 'URL input must use HTTP or HTTPS.');
|
|
200
|
+
}
|
|
201
|
+
const controller = new AbortController();
|
|
202
|
+
const timeout = setTimeout(() => controller.abort(), URL_TIMEOUT_MS);
|
|
203
|
+
let response;
|
|
204
|
+
try {
|
|
205
|
+
response = await fetch(url, { redirect: 'follow', signal: controller.signal });
|
|
206
|
+
} catch (error) {
|
|
207
|
+
const message = error?.name === 'AbortError' ? 'Timed out while fetching URL input.' : 'Failed to fetch URL input.';
|
|
208
|
+
throw new FileKitError('URL_FETCH_FAILED', message);
|
|
209
|
+
} finally {
|
|
210
|
+
clearTimeout(timeout);
|
|
211
|
+
}
|
|
212
|
+
if (!response.ok) throw new FileKitError('URL_FETCH_FAILED', `URL input returned HTTP ${response.status}.`);
|
|
213
|
+
const contentType = response.headers.get('content-type')?.toLowerCase() || '';
|
|
214
|
+
if (!contentType.startsWith('text/html') && !contentType.startsWith('text/plain')) {
|
|
215
|
+
throw new FileKitError('UNSUPPORTED_URL_CONTENT', 'URL input must return HTML or plain text.');
|
|
216
|
+
}
|
|
217
|
+
const declaredSize = Number(response.headers.get('content-length'));
|
|
218
|
+
if (Number.isFinite(declaredSize) && declaredSize > MAX_URL_RESPONSE_BYTES) {
|
|
219
|
+
throw new FileKitError('URL_RESPONSE_TOO_LARGE', 'URL input exceeds the 10 MiB response limit.');
|
|
220
|
+
}
|
|
221
|
+
const chunks = [];
|
|
222
|
+
let size = 0;
|
|
223
|
+
for await (const chunk of response.body || []) {
|
|
224
|
+
size += chunk.byteLength;
|
|
225
|
+
if (size > MAX_URL_RESPONSE_BYTES) throw new FileKitError('URL_RESPONSE_TOO_LARGE', 'URL input exceeds the 10 MiB response limit.');
|
|
226
|
+
chunks.push(chunk);
|
|
227
|
+
}
|
|
228
|
+
const source = new TextDecoder().decode(Buffer.concat(chunks));
|
|
229
|
+
if (contentType.startsWith('text/plain')) {
|
|
230
|
+
return { format: 'url-text', managed: false, filetype: 'document', title: filenameTitle(url.pathname) || url.hostname, body: source, warnings: [], sourceUrl: url.toString() };
|
|
231
|
+
}
|
|
232
|
+
return { format: 'url-html', managed: false, filetype: 'document', title: htmlTitle(source, filenameTitle(url.pathname) || url.hostname), body: htmlToMarkdown(source), warnings: ['HTML conversion may omit source-only styling.'], sourceUrl: url.toString() };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function detectFormat(filePath) {
|
|
236
|
+
const lower = String(filePath || '').toLowerCase();
|
|
237
|
+
if (lower.endsWith('.gpdoc.md')) return 'markdown';
|
|
238
|
+
const extension = path.extname(lower);
|
|
239
|
+
if (MARKDOWN_EXTENSIONS.has(extension)) return 'markdown';
|
|
240
|
+
if (HTML_EXTENSIONS.has(extension)) return 'html';
|
|
241
|
+
if (TEXT_EXTENSIONS.has(extension)) return 'text';
|
|
242
|
+
if (extension === '.json') return 'json';
|
|
243
|
+
if (extension === '.docx') return 'docx';
|
|
244
|
+
if (extension === '.pptx') return 'pptx';
|
|
245
|
+
if (UNSUPPORTED_BINARY_EXTENSIONS.has(extension)) throw new FileKitError('UNSUPPORTED_FORMAT', `Unsupported input format: ${extension}.`);
|
|
246
|
+
throw new FileKitError('UNSUPPORTED_FORMAT', `Unsupported input format: ${extension || 'unknown'}.`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function parseManagedMarkdown(source) {
|
|
250
|
+
const match = FRONT_MATTER.exec(source);
|
|
251
|
+
if (!match) return null;
|
|
252
|
+
const raw = match[1]?.trim() || '';
|
|
253
|
+
if (!raw) return null;
|
|
254
|
+
try {
|
|
255
|
+
const json = JSON.parse(raw);
|
|
256
|
+
if (isManagedMetadata(json)) return { metadata: json, body: source.slice(match[0].length), frontMatterFormat: 'json' };
|
|
257
|
+
if (isPlainObject(json)) throw new FileKitError('INVALID_GPDOC', 'Managed GPDoc metadata must include id, filename, and a supported filetype.');
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (error instanceof FileKitError) throw error;
|
|
260
|
+
}
|
|
261
|
+
let yamlRoot;
|
|
262
|
+
try {
|
|
263
|
+
yamlRoot = yaml.load(raw, { schema: yaml.JSON_SCHEMA, json: true });
|
|
264
|
+
} catch {
|
|
265
|
+
if (/gpdoc_metadata\s*:/.test(raw)) {
|
|
266
|
+
throw new FileKitError('INVALID_GPDOC', 'Managed GPDoc metadata must include id, filename, and a supported filetype.');
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
if (!isPlainObject(yamlRoot) || !Object.hasOwn(yamlRoot, 'gpdoc_metadata')) return null;
|
|
271
|
+
if (!isManagedMetadata(yamlRoot.gpdoc_metadata)) {
|
|
272
|
+
throw new FileKitError('INVALID_GPDOC', 'Managed GPDoc metadata must include id, filename, and a supported filetype.');
|
|
273
|
+
}
|
|
274
|
+
return { metadata: clone(yamlRoot.gpdoc_metadata), body: source.slice(match[0].length), frontMatterFormat: 'yaml' };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function parseGpdocJson(source) {
|
|
278
|
+
let json;
|
|
279
|
+
try {
|
|
280
|
+
json = JSON.parse(source);
|
|
281
|
+
} catch {
|
|
282
|
+
throw new FileKitError('INVALID_GPDOC', 'GPDoc JSON must be valid JSON.');
|
|
283
|
+
}
|
|
284
|
+
if (!isManagedMetadata(json) || !isPlainObject(json.content)) {
|
|
285
|
+
throw new FileKitError('INVALID_GPDOC', 'Managed GPDoc metadata must include id, filename, and a supported filetype.');
|
|
286
|
+
}
|
|
287
|
+
if (typeof json.content.document !== 'string') {
|
|
288
|
+
throw new FileKitError('INVALID_GPDOC', 'This CLI release supports document content only.');
|
|
289
|
+
}
|
|
290
|
+
return { metadata: clone(json), body: json.content.document };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function readDocument(source, filePath = '') {
|
|
294
|
+
const format = detectFormat(filePath);
|
|
295
|
+
if (format === 'docx') {
|
|
296
|
+
return { format, managed: false, filetype: 'document', title: filenameTitle(filePath), body: extractDocxMarkdown(source), warnings: ['DOCX conversion preserves text and headings, not full Word formatting.'] };
|
|
297
|
+
}
|
|
298
|
+
if (format === 'pptx') {
|
|
299
|
+
return { format, managed: false, filetype: 'document', title: filenameTitle(filePath), body: extractPptxMarkdown(source), warnings: ['PPTX conversion preserves slide text, not layout or speaker notes.'] };
|
|
300
|
+
}
|
|
301
|
+
if (format === 'html') {
|
|
302
|
+
return { format, managed: false, filetype: 'document', title: filenameTitle(filePath), body: htmlToMarkdown(source), warnings: ['HTML conversion may omit source-only styling.'] };
|
|
303
|
+
}
|
|
304
|
+
if (format === 'text') {
|
|
305
|
+
return { format, managed: false, filetype: 'document', title: filenameTitle(filePath), body: String(source), warnings: [] };
|
|
306
|
+
}
|
|
307
|
+
if (format === 'json') {
|
|
308
|
+
const parsed = parseGpdocJson(source);
|
|
309
|
+
return { format: 'gpdoc-json', managed: true, filetype: parsed.metadata.filetype, title: parsed.metadata.filename, body: parsed.body, metadata: parsed.metadata, warnings: [] };
|
|
310
|
+
}
|
|
311
|
+
const parsed = parseManagedMarkdown(String(source));
|
|
312
|
+
if (parsed) {
|
|
313
|
+
return { format: 'gpdoc-markdown', managed: true, filetype: parsed.metadata.filetype, title: parsed.metadata.filename, body: parsed.body, metadata: parsed.metadata, warnings: [] };
|
|
314
|
+
}
|
|
315
|
+
return { format: 'markdown', managed: false, filetype: 'document', title: filenameTitle(filePath), body: String(source), warnings: [] };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function createManagedDocument(title = 'Untitled', body = '') {
|
|
319
|
+
const now = new Date().toISOString();
|
|
320
|
+
return {
|
|
321
|
+
id: `file_${randomUUID()}`,
|
|
322
|
+
filename: title,
|
|
323
|
+
filetype: 'document',
|
|
324
|
+
created: now,
|
|
325
|
+
updated: now,
|
|
326
|
+
content: { document: String(body) },
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function serializeManagedMarkdown(metadata, body) {
|
|
331
|
+
const root = { gpdoc_metadata: { ...metadata, content: undefined } };
|
|
332
|
+
root.gpdoc_metadata.content = { document: String(body) };
|
|
333
|
+
return `---\n${yaml.dump(root, { schema: yaml.JSON_SCHEMA, noRefs: true, lineWidth: -1 }).trimEnd()}\n---\n${String(body)}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function markdownToText(markdown) {
|
|
337
|
+
return String(markdown)
|
|
338
|
+
.replace(/```[\s\S]*?```/g, (block) => block.replace(/^```[^\n]*\n?|```$/g, ''))
|
|
339
|
+
.replace(/!?(\[([^\]]*)\]\([^)]*\))/g, '$2')
|
|
340
|
+
.replace(/[*_~`>#]/g, '')
|
|
341
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
342
|
+
.trim();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function convertDocument(document, targetFormat) {
|
|
346
|
+
const target = String(targetFormat).toLowerCase();
|
|
347
|
+
const metadata = document.managed ? clone(document.metadata) : createManagedDocument(document.title, document.body);
|
|
348
|
+
metadata.content = { document: document.body };
|
|
349
|
+
if (target === 'gpdoc') return serializeManagedMarkdown(metadata, document.body);
|
|
350
|
+
if (target === 'markdown' || target === 'md') return document.body;
|
|
351
|
+
if (target === 'html') return `<!doctype html>\n<html><head><meta charset="utf-8"><title>${escapeHtml(document.title)}</title></head><body>\n${marked.parse(document.body)}\n</body></html>\n`;
|
|
352
|
+
if (target === 'text' || target === 'txt') return markdownToText(document.body);
|
|
353
|
+
if (target === 'json' || target === 'gpdoc-json') return `${JSON.stringify(metadata, null, 2)}\n`;
|
|
354
|
+
if (target === 'docx') return createDocx(document.body, document.title);
|
|
355
|
+
if (target === 'pdf') return createPdf(document.body, document.title);
|
|
356
|
+
throw new FileKitError('UNSUPPORTED_OUTPUT', `Unsupported output format: ${targetFormat}.`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function escapeHtml(value) {
|
|
360
|
+
return String(value).replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function inspectDocument(document, filePath) {
|
|
364
|
+
return {
|
|
365
|
+
format: document.format,
|
|
366
|
+
managed: document.managed,
|
|
367
|
+
filetype: document.filetype,
|
|
368
|
+
title: document.title,
|
|
369
|
+
path: filePath,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function validateDocument(document) {
|
|
374
|
+
if (document.managed && !isManagedMetadata(document.metadata)) {
|
|
375
|
+
throw new FileKitError('INVALID_GPDOC', 'Managed GPDoc metadata must include id, filename, and a supported filetype.');
|
|
376
|
+
}
|
|
377
|
+
return true;
|
|
378
|
+
}
|