@gpdoc/filekit 1.23.0 → 1.25.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 +8 -4
- package/src/index.js +89 -4
- package/src/markdown-edit.js +523 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gpdoc/filekit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.25.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Headless GPDoc file format detection, validation, and text conversion",
|
|
6
6
|
"repository": "https://github.com/repetere/gpdoc.git",
|
|
@@ -10,13 +10,17 @@
|
|
|
10
10
|
"publishConfig": {
|
|
11
11
|
"access": "public"
|
|
12
12
|
},
|
|
13
|
-
"exports":
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.js",
|
|
15
|
+
"./markdown-edit": "./src/markdown-edit.js"
|
|
16
|
+
},
|
|
14
17
|
"engines": {
|
|
15
18
|
"node": ">=20"
|
|
16
19
|
},
|
|
17
20
|
"dependencies": {
|
|
18
21
|
"fflate": "^0.8.2",
|
|
19
|
-
"js-yaml": "
|
|
20
|
-
"marked": "^12.0.0"
|
|
22
|
+
"js-yaml": "4.3.2",
|
|
23
|
+
"marked": "^12.0.0",
|
|
24
|
+
"yaml": "2.9.0"
|
|
21
25
|
}
|
|
22
26
|
}
|
package/src/index.js
CHANGED
|
@@ -4,6 +4,9 @@ import { strFromU8, strToU8, unzipSync, zipSync } from 'fflate';
|
|
|
4
4
|
import yaml from 'js-yaml';
|
|
5
5
|
import { marked } from 'marked';
|
|
6
6
|
|
|
7
|
+
// @spec CLI-TERMINAL-095, CLI-TERMINAL-096, CLI-TERMINAL-097, CLI-TERMINAL-098, CLI-TERMINAL-101
|
|
8
|
+
export { prepareMarkdownEdit, createMarkdownDraftPolicy, serializeMarkdownEdit } from './markdown-edit.js';
|
|
9
|
+
|
|
7
10
|
const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown', '.mdown', '.gpdoc.md']);
|
|
8
11
|
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
|
9
12
|
const TEXT_EXTENSIONS = new Set(['.txt', '.text']);
|
|
@@ -13,6 +16,52 @@ const FRONT_MATTER = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
|
13
16
|
const MAX_URL_RESPONSE_BYTES = 10 * 1024 * 1024;
|
|
14
17
|
const URL_TIMEOUT_MS = 15_000;
|
|
15
18
|
|
|
19
|
+
export const GPDOC_PAGE_BREAK = '<div style="page-break-after: always;"></div>';
|
|
20
|
+
|
|
21
|
+
export function resolveSlideDelimiter(metadata = {}) {
|
|
22
|
+
const configured = typeof metadata?.slideDelimiter === 'string'
|
|
23
|
+
? metadata.slideDelimiter.trim()
|
|
24
|
+
: '';
|
|
25
|
+
return configured || GPDOC_PAGE_BREAK;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function splitMarkdownSlides(markdown, delimiter = GPDOC_PAGE_BREAK) {
|
|
29
|
+
// @spec FILE-MODEL-006, FILE-MODEL-007, FILE-MODEL-008, FILE-MODEL-009
|
|
30
|
+
const boundary = typeof delimiter === 'string' && delimiter.trim()
|
|
31
|
+
? delimiter.trim()
|
|
32
|
+
: GPDOC_PAGE_BREAK;
|
|
33
|
+
const slides = [];
|
|
34
|
+
let current = [];
|
|
35
|
+
let fence = null;
|
|
36
|
+
for (const line of String(markdown ?? '').replace(/\r\n/g, '\n').split('\n')) {
|
|
37
|
+
const marker = line.match(/^\s*(`{3,}|~{3,})/u)?.[1] ?? null;
|
|
38
|
+
if (marker) {
|
|
39
|
+
if (!fence) fence = { character: marker[0], length: marker.length };
|
|
40
|
+
else if (marker[0] === fence.character && marker.length >= fence.length) fence = null;
|
|
41
|
+
current.push(line);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!fence && line.trim() === boundary) {
|
|
45
|
+
slides.push(current.join('\n').trim());
|
|
46
|
+
current = [];
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
current.push(line);
|
|
50
|
+
}
|
|
51
|
+
slides.push(current.join('\n').trim());
|
|
52
|
+
return slides;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function joinMarkdownSlides(slides, delimiter = GPDOC_PAGE_BREAK) {
|
|
56
|
+
// @spec FILE-MODEL-007, FILE-MODEL-008, FILE-MODEL-009
|
|
57
|
+
const boundary = typeof delimiter === 'string' && delimiter.trim()
|
|
58
|
+
? delimiter.trim()
|
|
59
|
+
: GPDOC_PAGE_BREAK;
|
|
60
|
+
return (Array.isArray(slides) ? slides : [slides ?? ''])
|
|
61
|
+
.map((slide) => String(slide ?? '').trim())
|
|
62
|
+
.join(`\n\n${boundary}\n\n`);
|
|
63
|
+
}
|
|
64
|
+
|
|
16
65
|
export class FileKitError extends Error {
|
|
17
66
|
constructor(code, message) {
|
|
18
67
|
super(message);
|
|
@@ -284,8 +333,15 @@ function parseGpdocJson(source) {
|
|
|
284
333
|
if (!isManagedMetadata(json) || !isPlainObject(json.content)) {
|
|
285
334
|
throw new FileKitError('INVALID_GPDOC', 'Managed GPDoc metadata must include id, filename, and a supported filetype.');
|
|
286
335
|
}
|
|
336
|
+
if (json.filetype === 'slides' && Array.isArray(json.content.slides)) {
|
|
337
|
+
return {
|
|
338
|
+
metadata: clone(json),
|
|
339
|
+
body: joinMarkdownSlides(json.content.slides, resolveSlideDelimiter(json)),
|
|
340
|
+
slides: json.content.slides.map((slide) => String(slide ?? '')),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
287
343
|
if (typeof json.content.document !== 'string') {
|
|
288
|
-
throw new FileKitError('INVALID_GPDOC', 'This
|
|
344
|
+
throw new FileKitError('INVALID_GPDOC', 'This GPDoc file does not contain supported document or slide content.');
|
|
289
345
|
}
|
|
290
346
|
return { metadata: clone(json), body: json.content.document };
|
|
291
347
|
}
|
|
@@ -306,11 +362,15 @@ export function readDocument(source, filePath = '') {
|
|
|
306
362
|
}
|
|
307
363
|
if (format === 'json') {
|
|
308
364
|
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: [] };
|
|
365
|
+
return { format: 'gpdoc-json', managed: true, filetype: parsed.metadata.filetype, title: parsed.metadata.filename, body: parsed.body, slides: parsed.slides, metadata: parsed.metadata, warnings: [] };
|
|
310
366
|
}
|
|
311
367
|
const parsed = parseManagedMarkdown(String(source));
|
|
312
368
|
if (parsed) {
|
|
313
|
-
|
|
369
|
+
const document = { format: 'gpdoc-markdown', managed: true, filetype: parsed.metadata.filetype, title: parsed.metadata.filename, body: parsed.body, metadata: parsed.metadata, warnings: [] };
|
|
370
|
+
if (parsed.metadata.filetype === 'slides') {
|
|
371
|
+
document.slides = splitMarkdownSlides(parsed.body, resolveSlideDelimiter(parsed.metadata));
|
|
372
|
+
}
|
|
373
|
+
return document;
|
|
314
374
|
}
|
|
315
375
|
return { format: 'markdown', managed: false, filetype: 'document', title: filenameTitle(filePath), body: String(source), warnings: [] };
|
|
316
376
|
}
|
|
@@ -327,6 +387,22 @@ export function createManagedDocument(title = 'Untitled', body = '') {
|
|
|
327
387
|
};
|
|
328
388
|
}
|
|
329
389
|
|
|
390
|
+
export function createManagedSlides(title = 'Untitled', slides = ['# Slide 1']) {
|
|
391
|
+
// @spec CLI-074, FILE-MODEL-011
|
|
392
|
+
const now = new Date().toISOString();
|
|
393
|
+
const normalizedSlides = (Array.isArray(slides) && slides.length ? slides : ['# Slide 1'])
|
|
394
|
+
.map((slide) => String(slide ?? ''));
|
|
395
|
+
return {
|
|
396
|
+
id: `file_${randomUUID()}`,
|
|
397
|
+
filename: title,
|
|
398
|
+
filetype: 'slides',
|
|
399
|
+
slideDelimiter: GPDOC_PAGE_BREAK,
|
|
400
|
+
created: now,
|
|
401
|
+
updated: now,
|
|
402
|
+
content: { slides: normalizedSlides },
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
330
406
|
export function serializeManagedMarkdown(metadata, body) {
|
|
331
407
|
// @spec CLI-001, CLI-066
|
|
332
408
|
// Markdown has a separate body. `content.document` is reserved for GPDoc JSON.
|
|
@@ -347,7 +423,16 @@ function markdownToText(markdown) {
|
|
|
347
423
|
export function convertDocument(document, targetFormat) {
|
|
348
424
|
const target = String(targetFormat).toLowerCase();
|
|
349
425
|
const metadata = document.managed ? clone(document.metadata) : createManagedDocument(document.title, document.body);
|
|
350
|
-
|
|
426
|
+
if (document.filetype === 'slides') {
|
|
427
|
+
metadata.slideDelimiter = resolveSlideDelimiter(metadata);
|
|
428
|
+
metadata.content = {
|
|
429
|
+
slides: Array.isArray(document.slides)
|
|
430
|
+
? document.slides.map((slide) => String(slide ?? ''))
|
|
431
|
+
: splitMarkdownSlides(document.body, metadata.slideDelimiter),
|
|
432
|
+
};
|
|
433
|
+
} else {
|
|
434
|
+
metadata.content = { document: document.body };
|
|
435
|
+
}
|
|
351
436
|
if (target === 'gpdoc') return serializeManagedMarkdown(metadata, document.body);
|
|
352
437
|
if (target === 'markdown' || target === 'md') return document.body;
|
|
353
438
|
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`;
|
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import { CST, Lexer } from 'yaml';
|
|
2
|
+
import yaml from 'js-yaml';
|
|
3
|
+
|
|
4
|
+
const MAX_BYTES = 1_048_576;
|
|
5
|
+
const MAX_METADATA_DEPTH = 40;
|
|
6
|
+
// Allow the document/envelope wrapper and transient scalar frames in the pinned
|
|
7
|
+
// loader. yamlValue enforces the exact metadata-root depth, including implicit maps.
|
|
8
|
+
const MAX_YAML_PARSER_FRAMES = MAX_METADATA_DEPTH + 4;
|
|
9
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
10
|
+
const FILE_TYPES = new Set(['document', 'notebook', 'slides', 'script', 'spreadsheet', 'drawing']);
|
|
11
|
+
const MESSAGES = Object.freeze({
|
|
12
|
+
UNSUPPORTED_FORMAT: 'Terminal editing supports Markdown documents only.',
|
|
13
|
+
INVALID_ENCODING: 'The document must contain valid UTF-8 and Unicode text.',
|
|
14
|
+
INVALID_GPDOC: 'The managed envelope cannot be preserved safely. Use explicit conversion to change its format.',
|
|
15
|
+
FILE_TOO_LARGE: 'The complete encoded document exceeds the 1 MiB limit.',
|
|
16
|
+
PROTECTED_METADATA: 'Review or live metadata requires read-only Preview.',
|
|
17
|
+
});
|
|
18
|
+
const envelopes = new WeakMap();
|
|
19
|
+
|
|
20
|
+
/** @template T @typedef {{ok: true, value: T} | {ok: false, error: {code: string, message: string}}} Result */
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Readonly<{body: string, metadata: Readonly<Record<string, unknown>>,
|
|
23
|
+
* editable: boolean, format: string, byteLength: number,
|
|
24
|
+
* projectedByteLength: (body: string) => Result<number>}>} EditingEnvelope
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// Internal errors never retain parser messages, source excerpts, or stacks.
|
|
28
|
+
function refuse(code) { throw code; }
|
|
29
|
+
function failure(code) {
|
|
30
|
+
const known = Object.hasOwn(MESSAGES, code) ? code : 'INVALID_GPDOC';
|
|
31
|
+
return { ok: false, error: { code: known, message: MESSAGES[known] } };
|
|
32
|
+
}
|
|
33
|
+
function result(operation) {
|
|
34
|
+
try { return { ok: true, value: operation() }; }
|
|
35
|
+
catch (code) { return failure(typeof code === 'string' ? code : 'INVALID_GPDOC'); }
|
|
36
|
+
}
|
|
37
|
+
function record(value) {
|
|
38
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
39
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
40
|
+
}
|
|
41
|
+
function unicode(text) {
|
|
42
|
+
if (typeof text !== 'string') refuse('INVALID_ENCODING');
|
|
43
|
+
for (let i = 0; i < text.length; i++) {
|
|
44
|
+
const code = text.charCodeAt(i);
|
|
45
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
46
|
+
const next = text.charCodeAt(++i);
|
|
47
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) refuse('INVALID_ENCODING');
|
|
48
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) refuse('INVALID_ENCODING');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function safeKey(key) {
|
|
52
|
+
unicode(key);
|
|
53
|
+
if (FORBIDDEN_KEYS.has(key)) refuse('INVALID_GPDOC');
|
|
54
|
+
}
|
|
55
|
+
function freeze(value) {
|
|
56
|
+
if (value && typeof value === 'object') {
|
|
57
|
+
for (const child of Object.values(value)) freeze(child);
|
|
58
|
+
Object.freeze(value);
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// @spec CLI-TERMINAL-099, CLI-TERMINAL-136, CLI-TERMINAL-137
|
|
64
|
+
// Lexer tokens distinguish syntax from
|
|
65
|
+
// comments/quoted/block text; this recognizes root keys without constructing the
|
|
66
|
+
// offending value tree. The scan keeps counters and at most three key names.
|
|
67
|
+
function hasManagedRootKey(raw) {
|
|
68
|
+
const keys = new Set();
|
|
69
|
+
const legacy = raw.trimStart().startsWith('{');
|
|
70
|
+
let root;
|
|
71
|
+
let rootIndent;
|
|
72
|
+
let flowDepth = 0;
|
|
73
|
+
let column = 0;
|
|
74
|
+
let indent = 0;
|
|
75
|
+
let lineStart = true;
|
|
76
|
+
let indentationEnded = false;
|
|
77
|
+
let scalarNext = false;
|
|
78
|
+
let flowKey = true;
|
|
79
|
+
let explicitKey = false;
|
|
80
|
+
let pendingKey;
|
|
81
|
+
let block;
|
|
82
|
+
const commitKey = () => {
|
|
83
|
+
if (['gpdoc_metadata', 'filetype', 'id', 'filename'].includes(pendingKey)) keys.add(pendingKey);
|
|
84
|
+
pendingKey = undefined;
|
|
85
|
+
return keys.has('gpdoc_metadata') || (legacy && (keys.has('filetype') || (keys.has('id') && keys.has('filename'))));
|
|
86
|
+
};
|
|
87
|
+
for (const source of new Lexer().lex(raw)) {
|
|
88
|
+
if (!scalarNext && source === CST.SCALAR) { scalarNext = true; continue; }
|
|
89
|
+
if (!scalarNext && [CST.DOCUMENT, CST.FLOW_END].includes(source)) continue;
|
|
90
|
+
const type = scalarNext ? 'scalar' : CST.tokenType(source);
|
|
91
|
+
scalarNext = false;
|
|
92
|
+
if (type === 'doc-end') return false;
|
|
93
|
+
const startColumn = column;
|
|
94
|
+
const atLineStart = lineStart;
|
|
95
|
+
const lastNewline = source.lastIndexOf('\n');
|
|
96
|
+
column = lastNewline < 0 ? column + source.length : source.length - lastNewline - 1;
|
|
97
|
+
if (type === 'newline') { indent = 0; lineStart = true; indentationEnded = false; continue; }
|
|
98
|
+
if (type === 'space') { if (lineStart && !indentationEnded) indent += source.length; continue; }
|
|
99
|
+
if (['anchor', 'tag'].includes(type)) { indentationEnded = true; continue; }
|
|
100
|
+
if (['comment', 'directive-line', 'byte-order-mark', 'doc-start'].includes(type)) continue;
|
|
101
|
+
indentationEnded = true;
|
|
102
|
+
lineStart = lastNewline >= 0 && column === 0;
|
|
103
|
+
const scalar = ['scalar', 'single-quoted-scalar', 'double-quoted-scalar'].includes(type);
|
|
104
|
+
if (!root) {
|
|
105
|
+
rootIndent = indent;
|
|
106
|
+
if (type === 'flow-map-start') root = 'flow';
|
|
107
|
+
else if (scalar || type === 'explicit-key-ind') root = 'block';
|
|
108
|
+
else return false; // A sequence or literal root cannot own managed metadata.
|
|
109
|
+
}
|
|
110
|
+
const keyPosition = root === 'flow' ? flowDepth === 1 && flowKey
|
|
111
|
+
: flowDepth === 0 && ((atLineStart && indent === rootIndent) || explicitKey);
|
|
112
|
+
if (type === 'explicit-key-ind' && flowDepth === 0 && indent === rootIndent) {
|
|
113
|
+
explicitKey = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (type === 'block-scalar-header') {
|
|
117
|
+
block = { source, key: keyPosition, explicit: explicitKey, indent: rootIndent };
|
|
118
|
+
pendingKey = undefined;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (scalar) {
|
|
122
|
+
if (block?.key || (!block && keyPosition)) {
|
|
123
|
+
const token = block ? {
|
|
124
|
+
type: 'block-scalar', source, indent: block.indent, offset: 0,
|
|
125
|
+
props: [{ type: 'block-scalar-header', source: block.source, offset: 0, indent: block.indent },
|
|
126
|
+
{ type: 'newline', source: '\n', offset: 0, indent: block.indent }],
|
|
127
|
+
} : { type, source, offset: 0, indent: startColumn };
|
|
128
|
+
try { pendingKey = CST.resolveAsScalar(token)?.value; }
|
|
129
|
+
catch { pendingKey = undefined; }
|
|
130
|
+
}
|
|
131
|
+
if ((explicitKey || block?.explicit) && commitKey()) return true;
|
|
132
|
+
block = undefined;
|
|
133
|
+
explicitKey = false;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (type === 'map-value-ind' && (root === 'block' ? flowDepth === 0 : flowDepth === 1)) {
|
|
137
|
+
if (commitKey()) return true;
|
|
138
|
+
flowKey = false;
|
|
139
|
+
explicitKey = false;
|
|
140
|
+
}
|
|
141
|
+
if (root === 'flow' && flowDepth === 1 && ['comma', 'flow-map-end'].includes(type)) {
|
|
142
|
+
if (commitKey()) return true;
|
|
143
|
+
flowKey = true;
|
|
144
|
+
}
|
|
145
|
+
if (type === 'flow-map-start' || type === 'flow-seq-start') { flowDepth++; pendingKey = undefined; }
|
|
146
|
+
else if (type === 'flow-map-end' || type === 'flow-seq-end') flowDepth = Math.max(0, flowDepth - 1);
|
|
147
|
+
}
|
|
148
|
+
// A missing flow terminator is a syntax error, not permission to demote a
|
|
149
|
+
// recognized managed key to editable plain text.
|
|
150
|
+
return root === 'flow' && flowDepth === 1 && flowKey && commitKey();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// @spec CLI-TERMINAL-099, CLI-TERMINAL-136, CLI-TERMINAL-137
|
|
154
|
+
// Construct only values, never a full CST/AST alongside them. Lexical rejection
|
|
155
|
+
// prevents alias expansion; the listener bounds parser frames before descent.
|
|
156
|
+
function boundedYamlDocument(raw) {
|
|
157
|
+
let scalarNext = false;
|
|
158
|
+
for (const source of new Lexer().lex(raw)) {
|
|
159
|
+
if (scalarNext) { scalarNext = false; continue; }
|
|
160
|
+
if (source === CST.SCALAR) { scalarNext = true; continue; }
|
|
161
|
+
const type = CST.tokenType(source);
|
|
162
|
+
if (type === 'alias') refuse('INVALID_GPDOC');
|
|
163
|
+
if (type === 'directive-line' && !/^%YAML[ \t]+1\.2[ \t]*(?:#.*)?$/.test(source)) refuse('INVALID_GPDOC');
|
|
164
|
+
}
|
|
165
|
+
const frames = [];
|
|
166
|
+
const nextIndicator = (state) => {
|
|
167
|
+
let offset = state.position;
|
|
168
|
+
while (offset < state.input.length) {
|
|
169
|
+
if (' \t\r\n'.includes(state.input[offset])) offset++;
|
|
170
|
+
else if (state.input[offset] === '#') {
|
|
171
|
+
while (offset < state.input.length && state.input[offset] !== '\n') offset++;
|
|
172
|
+
} else break;
|
|
173
|
+
}
|
|
174
|
+
return state.input[offset];
|
|
175
|
+
};
|
|
176
|
+
return yaml.load(raw, {
|
|
177
|
+
schema: yaml.JSON_SCHEMA, json: false,
|
|
178
|
+
onWarning: () => refuse('INVALID_GPDOC'),
|
|
179
|
+
listener(event, state) {
|
|
180
|
+
if (event === 'open') {
|
|
181
|
+
if (frames.length >= MAX_YAML_PARSER_FRAMES) refuse('INVALID_GPDOC');
|
|
182
|
+
const parent = frames.at(-1);
|
|
183
|
+
frames.push({ value: parent?.expectValue === true, expectValue: false, nonStringKey: false, keyCandidate: undefined, distinctKeys: false });
|
|
184
|
+
if (parent) parent.expectValue = false;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const frame = frames.pop();
|
|
188
|
+
// A wrapper frame returns the very same collection as its only child.
|
|
189
|
+
// An actual complex key is never the mapping that contains that key.
|
|
190
|
+
if (state.kind === 'mapping' && frame.nonStringKey
|
|
191
|
+
&& (frame.distinctKeys || frame.keyCandidate !== state.result)) refuse('INVALID_GPDOC');
|
|
192
|
+
const parent = frames.at(-1);
|
|
193
|
+
if (!parent) return;
|
|
194
|
+
const indicator = nextIndicator(state);
|
|
195
|
+
if (indicator === ':') {
|
|
196
|
+
if (typeof state.result !== 'string') refuse('INVALID_GPDOC');
|
|
197
|
+
safeKey(state.result);
|
|
198
|
+
parent.expectValue = true;
|
|
199
|
+
} else if (!frame.value && typeof state.result !== 'string') {
|
|
200
|
+
// Bare flow-map and explicit block keys can omit the value indicator.
|
|
201
|
+
// A sequence/scalar parent ignores this marker when its frame closes.
|
|
202
|
+
if (parent.nonStringKey && parent.keyCandidate !== state.result) parent.distinctKeys = true;
|
|
203
|
+
if (!parent.nonStringKey) parent.keyCandidate = state.result;
|
|
204
|
+
parent.nonStringKey = true;
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// @spec CLI-TERMINAL-098, CLI-TERMINAL-099, CLI-TERMINAL-137
|
|
211
|
+
// Strict JSON grammar with decoded-key uniqueness and the original body token span.
|
|
212
|
+
function parseJson(source) {
|
|
213
|
+
let offset = 0;
|
|
214
|
+
let bodySpan;
|
|
215
|
+
const number = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
216
|
+
const whitespace = () => { while (' \t\r\n'.includes(source[offset]) && offset < source.length) offset++; };
|
|
217
|
+
const string = () => {
|
|
218
|
+
const start = offset;
|
|
219
|
+
if (source[offset++] !== '"') refuse('INVALID_GPDOC');
|
|
220
|
+
while (offset < source.length) {
|
|
221
|
+
const character = source[offset++];
|
|
222
|
+
if (character === '\\') { offset++; continue; }
|
|
223
|
+
if (character === '"') {
|
|
224
|
+
let value;
|
|
225
|
+
try { value = JSON.parse(source.slice(start, offset)); }
|
|
226
|
+
catch { refuse('INVALID_GPDOC'); }
|
|
227
|
+
unicode(value);
|
|
228
|
+
return value;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
refuse('INVALID_GPDOC');
|
|
232
|
+
};
|
|
233
|
+
function value(depth, path) {
|
|
234
|
+
if (depth > MAX_METADATA_DEPTH) refuse('INVALID_GPDOC');
|
|
235
|
+
whitespace();
|
|
236
|
+
const start = offset;
|
|
237
|
+
if (source[offset] === '"') {
|
|
238
|
+
const parsed = string();
|
|
239
|
+
if (path.length === 2 && path[0] === 'content' && path[1] === 'document') bodySpan = [start, offset];
|
|
240
|
+
return parsed;
|
|
241
|
+
}
|
|
242
|
+
if (source[offset] === '{' || source[offset] === '[') {
|
|
243
|
+
const object = source[offset++] === '{';
|
|
244
|
+
const end = object ? '}' : ']';
|
|
245
|
+
const output = object ? {} : [];
|
|
246
|
+
const keys = new Set();
|
|
247
|
+
whitespace();
|
|
248
|
+
if (source[offset] === end) { offset++; return output; }
|
|
249
|
+
while (offset < source.length) {
|
|
250
|
+
let key = output.length;
|
|
251
|
+
if (object) {
|
|
252
|
+
whitespace();
|
|
253
|
+
key = string();
|
|
254
|
+
safeKey(key);
|
|
255
|
+
if (keys.has(key)) refuse('INVALID_GPDOC');
|
|
256
|
+
keys.add(key);
|
|
257
|
+
whitespace();
|
|
258
|
+
if (source[offset++] !== ':') refuse('INVALID_GPDOC');
|
|
259
|
+
}
|
|
260
|
+
output[key] = value(depth + 1, [...path, key]);
|
|
261
|
+
whitespace();
|
|
262
|
+
const separator = source[offset++];
|
|
263
|
+
if (separator === end) return output;
|
|
264
|
+
if (separator !== ',') refuse('INVALID_GPDOC');
|
|
265
|
+
}
|
|
266
|
+
refuse('INVALID_GPDOC');
|
|
267
|
+
}
|
|
268
|
+
for (const [token, parsed] of [['true', true], ['false', false], ['null', null]]) {
|
|
269
|
+
if (source.startsWith(token, offset)) { offset += token.length; return parsed; }
|
|
270
|
+
}
|
|
271
|
+
number.lastIndex = offset;
|
|
272
|
+
const match = number.exec(source);
|
|
273
|
+
if (!match) refuse('INVALID_GPDOC');
|
|
274
|
+
offset = number.lastIndex;
|
|
275
|
+
const parsed = Number(match[0]);
|
|
276
|
+
if (!Number.isFinite(parsed)) refuse('INVALID_GPDOC');
|
|
277
|
+
return parsed;
|
|
278
|
+
}
|
|
279
|
+
const parsed = value(0, []);
|
|
280
|
+
whitespace();
|
|
281
|
+
if (offset !== source.length) refuse('INVALID_GPDOC');
|
|
282
|
+
return { parsed, bodySpan };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// @spec CLI-TERMINAL-099, CLI-TERMINAL-136, CLI-TERMINAL-137
|
|
286
|
+
// Validate the data-only value tree in place, without making a second copy.
|
|
287
|
+
function yamlValue(value, depth = 0) {
|
|
288
|
+
if (depth > MAX_METADATA_DEPTH) refuse('INVALID_GPDOC');
|
|
289
|
+
if (value === null || typeof value !== 'object') {
|
|
290
|
+
if (typeof value === 'string') unicode(value);
|
|
291
|
+
else if (value !== null && typeof value !== 'boolean'
|
|
292
|
+
&& !(typeof value === 'number' && Number.isFinite(value))) refuse('INVALID_GPDOC');
|
|
293
|
+
return value;
|
|
294
|
+
}
|
|
295
|
+
if (Array.isArray(value)) {
|
|
296
|
+
for (const child of value) yamlValue(child, depth + 1);
|
|
297
|
+
return value;
|
|
298
|
+
}
|
|
299
|
+
if (!record(value)) refuse('INVALID_GPDOC');
|
|
300
|
+
for (const [key, child] of Object.entries(value)) {
|
|
301
|
+
safeKey(key);
|
|
302
|
+
yamlValue(child, depth + 1);
|
|
303
|
+
}
|
|
304
|
+
return value;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function validateMetadata(metadata, json = false) {
|
|
308
|
+
if (!record(metadata) || typeof metadata.id !== 'string' || !metadata.id.trim()
|
|
309
|
+
|| typeof metadata.filename !== 'string' || !metadata.filename.trim()
|
|
310
|
+
|| !FILE_TYPES.has(metadata.filetype)
|
|
311
|
+
|| (metadata.meta != null && !record(metadata.meta))) refuse('INVALID_GPDOC');
|
|
312
|
+
if (metadata.filetype !== 'document') refuse('UNSUPPORTED_FORMAT');
|
|
313
|
+
if (json) {
|
|
314
|
+
if (!record(metadata.content) || typeof metadata.content.document !== 'string') refuse('INVALID_GPDOC');
|
|
315
|
+
} else if (Object.hasOwn(metadata, 'content')) refuse('INVALID_GPDOC');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// @spec CLI-TERMINAL-102, CLI-TERMINAL-138
|
|
319
|
+
function safeReview(review, collection) {
|
|
320
|
+
if (review == null) return true;
|
|
321
|
+
if (!record(review)) return false;
|
|
322
|
+
if (Object.keys(review).length === 0) return true;
|
|
323
|
+
const allowed = collection === 'threads' ? [collection, 'metadata'] : [collection, 'enabled'];
|
|
324
|
+
if (Object.keys(review).some((key) => !allowed.includes(key))) return false;
|
|
325
|
+
if (!record(review[collection]) || Object.keys(review[collection]).length) return false;
|
|
326
|
+
if (collection === 'items') return !Object.hasOwn(review, 'enabled') || typeof review.enabled === 'boolean';
|
|
327
|
+
if (!Object.hasOwn(review, 'metadata')) return true;
|
|
328
|
+
if (!record(review.metadata)) return false;
|
|
329
|
+
return Object.entries(review.metadata).every(([key, value]) => {
|
|
330
|
+
if (key === 'totalComments' || key === 'resolvedComments') return value === 0;
|
|
331
|
+
if (key === 'lastCommentAt') return value === null || isoDate(value);
|
|
332
|
+
return false;
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
function isoDate(value) {
|
|
336
|
+
if (typeof value !== 'string') return false;
|
|
337
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/.exec(value);
|
|
338
|
+
if (!match) return false;
|
|
339
|
+
const [year, month, day, hour, minute, second, zoneHour, zoneMinute] = match.slice(1).map((part) => Number(part ?? 0));
|
|
340
|
+
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
341
|
+
const monthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
342
|
+
return month >= 1 && month <= 12 && day >= 1 && day <= monthDays[month - 1]
|
|
343
|
+
&& hour < 24 && minute < 60 && second < 60
|
|
344
|
+
&& zoneHour < 24 && zoneMinute < 60;
|
|
345
|
+
}
|
|
346
|
+
function safeProvenance(value) {
|
|
347
|
+
if (!record(value)) return false;
|
|
348
|
+
return Object.entries(value).every(([key, entry]) => {
|
|
349
|
+
if (['liveDocId', 'shareId', 'serverVersion', 'provider'].includes(key)) return typeof entry === 'string' && entry.trim().length > 0;
|
|
350
|
+
if (['lastSyncedAt', 'detachedAt'].includes(key)) return isoDate(entry);
|
|
351
|
+
return false;
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
function safeLive(live) {
|
|
355
|
+
if (live == null) return true;
|
|
356
|
+
if (!record(live)) return false;
|
|
357
|
+
const keys = Object.keys(live);
|
|
358
|
+
if (!keys.length) return true;
|
|
359
|
+
for (const [key, value] of Object.entries(live)) {
|
|
360
|
+
if (key === 'enabled') { if (value !== false) return false; }
|
|
361
|
+
else if (key === 'liveDocId') return false;
|
|
362
|
+
else if (key === 'status') { if (!['detached', 'archived'].includes(value)) return false; }
|
|
363
|
+
else if (key === 'detachedFrom') { if (!safeProvenance(value)) return false; }
|
|
364
|
+
else if (key === 'lastSyncedAt') { if (!isoDate(value)) return false; }
|
|
365
|
+
else if (['shareId', 'serverVersion', 'provider'].includes(key)) {
|
|
366
|
+
if (typeof value !== 'string' || !value.trim()) return false;
|
|
367
|
+
} else return false;
|
|
368
|
+
}
|
|
369
|
+
// A share id by itself is provenance. Other nonempty descriptors must explicitly detach.
|
|
370
|
+
return keys.every((key) => key === 'shareId') || live.enabled === false
|
|
371
|
+
|| live.status === 'detached' || live.status === 'archived';
|
|
372
|
+
}
|
|
373
|
+
function editableMetadata(metadata) {
|
|
374
|
+
const meta = metadata.meta;
|
|
375
|
+
if (!meta) return true;
|
|
376
|
+
if (meta.liveDocId != null && meta.liveDocId !== '') return false;
|
|
377
|
+
if (meta.shared != null && meta.shared !== false) return false;
|
|
378
|
+
return safeReview(meta.comments, 'threads') && safeReview(meta.suggestions, 'items') && safeLive(meta.live);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// @spec CLI-TERMINAL-023, CLI-TERMINAL-096, CLI-TERMINAL-097, CLI-TERMINAL-098, CLI-TERMINAL-136
|
|
382
|
+
function parseSource(source, formatHint) {
|
|
383
|
+
const bom = source.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
384
|
+
const text = source.slice(bom.length);
|
|
385
|
+
if (formatHint === 'gpdoc-json') {
|
|
386
|
+
const { parsed: metadata, bodySpan } = parseJson(text);
|
|
387
|
+
validateMetadata(metadata, true);
|
|
388
|
+
if (!bodySpan) refuse('INVALID_GPDOC');
|
|
389
|
+
const body = metadata.content.document;
|
|
390
|
+
delete metadata.content.document;
|
|
391
|
+
return { format: 'gpdoc-json', body, metadata, protectedValue: metadata,
|
|
392
|
+
prefix: bom + text.slice(0, bodySpan[0]), suffix: text.slice(bodySpan[1]), token: text.slice(...bodySpan) };
|
|
393
|
+
}
|
|
394
|
+
const plain = { format: 'markdown', body: text, metadata: {}, protectedValue: {}, prefix: bom, suffix: '', token: text };
|
|
395
|
+
const opening = /^---[ \t]*\r?\n/.exec(text);
|
|
396
|
+
if (!opening) return plain;
|
|
397
|
+
const rest = text.slice(opening[0].length);
|
|
398
|
+
const closing = /^---[ \t]*(?:\r?\n|$)/m.exec(rest);
|
|
399
|
+
const raw = closing ? rest.slice(0, closing.index) : rest;
|
|
400
|
+
if (!hasManagedRootKey(raw)) return plain;
|
|
401
|
+
if (!closing || !closing[0].endsWith('\n')) refuse('INVALID_GPDOC');
|
|
402
|
+
const root = boundedYamlDocument(raw);
|
|
403
|
+
if (!record(root)) refuse('INVALID_GPDOC');
|
|
404
|
+
const managed = Object.hasOwn(root, 'gpdoc_metadata');
|
|
405
|
+
let metadata;
|
|
406
|
+
let protectedValue;
|
|
407
|
+
if (managed) {
|
|
408
|
+
// The metadata root is depth zero, independent of its YAML envelope wrapper.
|
|
409
|
+
protectedValue = {};
|
|
410
|
+
for (const [key, child] of Object.entries(root)) {
|
|
411
|
+
safeKey(key);
|
|
412
|
+
protectedValue[key] = yamlValue(child);
|
|
413
|
+
}
|
|
414
|
+
metadata = protectedValue.gpdoc_metadata;
|
|
415
|
+
} else {
|
|
416
|
+
metadata = parseJson(raw).parsed;
|
|
417
|
+
protectedValue = metadata;
|
|
418
|
+
}
|
|
419
|
+
validateMetadata(metadata);
|
|
420
|
+
const bodyOffset = opening[0].length + closing.index + closing[0].length;
|
|
421
|
+
const body = text.slice(bodyOffset);
|
|
422
|
+
return { format: 'gpdoc-markdown', body, metadata, protectedValue,
|
|
423
|
+
prefix: bom + text.slice(0, bodyOffset), suffix: '', token: body };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// @spec CLI-TERMINAL-080, CLI-TERMINAL-081, CLI-TERMINAL-137
|
|
427
|
+
function projectedSize(state, body) {
|
|
428
|
+
unicode(body);
|
|
429
|
+
// This cheap lower bound avoids allocating an encoded copy of oversized input.
|
|
430
|
+
if (body.length > MAX_BYTES) refuse('FILE_TOO_LARGE');
|
|
431
|
+
if (body === state.body) return state.byteLength;
|
|
432
|
+
let size = state.overhead + Buffer.byteLength(body, 'utf8');
|
|
433
|
+
if (state.format === 'gpdoc-json') {
|
|
434
|
+
size += 2; // The two JSON string quotes.
|
|
435
|
+
for (let i = 0; i < body.length; i++) {
|
|
436
|
+
const code = body.charCodeAt(i);
|
|
437
|
+
if (code === 34 || code === 92 || [8, 9, 10, 12, 13].includes(code)) size++;
|
|
438
|
+
else if (code < 32) size += 5;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (size > MAX_BYTES) refuse('FILE_TOO_LARGE');
|
|
442
|
+
return size;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// @spec CLI-TERMINAL-095, CLI-TERMINAL-096, CLI-TERMINAL-099, CLI-TERMINAL-102, CLI-TERMINAL-136, CLI-TERMINAL-137, CLI-TERMINAL-138
|
|
446
|
+
/**
|
|
447
|
+
* Prepare an immutable, path-free source envelope without converting the document.
|
|
448
|
+
* Traces: CLI-TERMINAL-095 through 102, CLI-TERMINAL-136 through 138 (pure portions).
|
|
449
|
+
* Tests: TST-CLI-TERMINAL-095 through 102, TST-CLI-TERMINAL-136 through 138.
|
|
450
|
+
* @param {Uint8Array} bytes
|
|
451
|
+
* @param {'markdown'|'gpdoc-json'} formatHint
|
|
452
|
+
* @returns {Result<EditingEnvelope>}
|
|
453
|
+
*/
|
|
454
|
+
export function prepareMarkdownEdit(bytes, formatHint) {
|
|
455
|
+
return result(() => {
|
|
456
|
+
if (!['markdown', 'gpdoc-json'].includes(formatHint)) refuse('UNSUPPORTED_FORMAT');
|
|
457
|
+
if (!(bytes instanceof Uint8Array)) refuse('INVALID_ENCODING');
|
|
458
|
+
if (bytes.byteLength > MAX_BYTES) refuse('FILE_TOO_LARGE');
|
|
459
|
+
let source;
|
|
460
|
+
try { source = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); }
|
|
461
|
+
catch { refuse('INVALID_ENCODING'); }
|
|
462
|
+
unicode(source);
|
|
463
|
+
const parsed = parseSource(source, formatHint);
|
|
464
|
+
const state = { ...parsed, source, formatHint, byteLength: bytes.byteLength,
|
|
465
|
+
overhead: Buffer.byteLength(parsed.prefix + parsed.suffix, 'utf8'),
|
|
466
|
+
editable: editableMetadata(parsed.metadata) };
|
|
467
|
+
const envelope = Object.freeze({
|
|
468
|
+
body: state.body,
|
|
469
|
+
metadata: freeze(state.metadata),
|
|
470
|
+
editable: state.editable,
|
|
471
|
+
format: state.format,
|
|
472
|
+
byteLength: state.byteLength,
|
|
473
|
+
projectedByteLength: (body) => result(() => projectedSize(state, body)),
|
|
474
|
+
});
|
|
475
|
+
envelopes.set(envelope, state);
|
|
476
|
+
return envelope;
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// @spec CLI-TERMINAL-016, CLI-TERMINAL-080, CLI-TERMINAL-081, CLI-TERMINAL-095, CLI-TERMINAL-102, CLI-TERMINAL-136, CLI-TERMINAL-137
|
|
481
|
+
/**
|
|
482
|
+
* Validate candidate admission without parsing incomplete Markdown or acquiring I/O authority.
|
|
483
|
+
* Traces: CLI-TERMINAL-016, CLI-TERMINAL-080, CLI-TERMINAL-081, CLI-TERMINAL-095, CLI-TERMINAL-102, CLI-TERMINAL-136, CLI-TERMINAL-137.
|
|
484
|
+
* Tests: TST-CLI-TERMINAL-080, TST-CLI-TERMINAL-095, TST-CLI-TERMINAL-102, TST-CLI-TERMINAL-136, TST-CLI-TERMINAL-137.
|
|
485
|
+
* @param {EditingEnvelope} envelope
|
|
486
|
+
* @returns {Readonly<{editable: boolean, checkCandidate: (body: string) => Result<void>}>}
|
|
487
|
+
*/
|
|
488
|
+
export function createMarkdownDraftPolicy(envelope) {
|
|
489
|
+
const state = envelopes.get(envelope);
|
|
490
|
+
return Object.freeze({
|
|
491
|
+
editable: state?.editable === true,
|
|
492
|
+
checkCandidate: (body) => result(() => {
|
|
493
|
+
if (!state) refuse('INVALID_GPDOC');
|
|
494
|
+
if (!state.editable && body !== state.body) refuse('PROTECTED_METADATA');
|
|
495
|
+
projectedSize(state, body);
|
|
496
|
+
}),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// @spec CLI-TERMINAL-023, CLI-TERMINAL-095, CLI-TERMINAL-096, CLI-TERMINAL-097, CLI-TERMINAL-098, CLI-TERMINAL-100, CLI-TERMINAL-101, CLI-TERMINAL-102, CLI-TERMINAL-136, CLI-TERMINAL-137
|
|
501
|
+
/**
|
|
502
|
+
* Replace only the body's bytes, then reparse and compare all protected envelope data.
|
|
503
|
+
* Traces: CLI-TERMINAL-023, CLI-TERMINAL-095 through 102 (pure portions), CLI-TERMINAL-136, CLI-TERMINAL-137.
|
|
504
|
+
* Tests: TST-CLI-TERMINAL-023, TST-CLI-TERMINAL-095 through 102, TST-CLI-TERMINAL-136, TST-CLI-TERMINAL-137.
|
|
505
|
+
* @param {EditingEnvelope} envelope
|
|
506
|
+
* @param {string} body
|
|
507
|
+
* @returns {Result<Uint8Array>}
|
|
508
|
+
*/
|
|
509
|
+
export function serializeMarkdownEdit(envelope, body) {
|
|
510
|
+
return result(() => {
|
|
511
|
+
const state = envelopes.get(envelope);
|
|
512
|
+
if (!state) refuse('INVALID_GPDOC');
|
|
513
|
+
if (!state.editable && body !== state.body) refuse('PROTECTED_METADATA');
|
|
514
|
+
projectedSize(state, body);
|
|
515
|
+
const token = body === state.body ? state.token : state.format === 'gpdoc-json' ? JSON.stringify(body) : body;
|
|
516
|
+
const source = state.prefix + token + state.suffix;
|
|
517
|
+
const reparsed = parseSource(source, state.formatHint);
|
|
518
|
+
if (reparsed.format !== state.format || reparsed.body !== body
|
|
519
|
+
|| reparsed.prefix !== state.prefix || reparsed.suffix !== state.suffix
|
|
520
|
+
|| JSON.stringify(reparsed.protectedValue) !== JSON.stringify(state.protectedValue)) refuse('INVALID_GPDOC');
|
|
521
|
+
return new TextEncoder().encode(source);
|
|
522
|
+
});
|
|
523
|
+
}
|