@gpdoc/filekit 1.24.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 +3 -0
- 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']);
|
|
@@ -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
|
+
}
|