@lensmap/document 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +31 -2
- package/dist/export-schema.d.ts +1 -0
- package/dist/export-schema.js +9 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +416 -0
- package/dist/v1/schema.d.ts +32 -0
- package/dist/v1/schema.js +35 -0
- package/package.json +45 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lensmap contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lensmap/document
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Experimental, host-independent Lensmap document engine. Provides YAML analysis,
|
|
4
|
+
semantic graphs, revision-bound source bindings, session identities and bounded
|
|
5
|
+
source-preserving content edits. Does not own layout, browser rendering or LSP.
|
|
6
|
+
|
|
7
|
+
Requires Node 24.16 or newer. ESM only. The API and `version: v1` authoring format
|
|
8
|
+
are incomplete and experimental; `0.1.x` is not a promise of a stable schema.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install @lensmap/document@next
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
import { DocumentSession } from "@lensmap/document";
|
|
16
|
+
|
|
17
|
+
const session = new DocumentSession(
|
|
18
|
+
"file:///notes.lensmap",
|
|
19
|
+
"version: v1\nnodes: [First thought]\n",
|
|
20
|
+
1,
|
|
21
|
+
);
|
|
22
|
+
console.log(session.snapshot());
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The root export includes `DocumentSchema`, `generateJSONSchema` and the session
|
|
26
|
+
API. `@lensmap/document/v1` exposes the experimental structural schema. Both
|
|
27
|
+
exports include TypeScript declarations. Thought/edge keys are attachment-local
|
|
28
|
+
internal identities, not authored persistent IDs. Source/LSP positions use
|
|
29
|
+
zero-based UTF-16 units.
|
|
30
|
+
|
|
31
|
+
See the [source and format documentation](https://github.com/nicodoggie/lensmap)
|
|
32
|
+
for supported syntax and limitations. MIT licensed; see `LICENSE`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { generateJSONSchema } from "./index.js";
|
|
4
|
+
const target = process.argv[2];
|
|
5
|
+
if (!target)
|
|
6
|
+
throw new Error("Supply the generated schema's output filename.");
|
|
7
|
+
const path = resolve(target);
|
|
8
|
+
await mkdir(dirname(path), { recursive: true });
|
|
9
|
+
await writeFile(path, `${JSON.stringify({ ...generateJSONSchema(), title: "Lensmap experimental v1", $comment: "Generated from the owning Zod input schema. Not a public compatibility promise; document-wide IDs/references are checked by the engine." }, null, 2)}\n`);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export type { LensmapDocument, Reference, RichNode } from "./v1/schema.js";
|
|
3
|
+
export { DocumentSchema } from "./v1/schema.js";
|
|
4
|
+
export declare function generateJSONSchema(): z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
|
|
5
|
+
version: z.ZodLiteral<"v1">;
|
|
6
|
+
nodes: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodType<import("./v1/schema.js").RichNode, unknown, z.core.$ZodTypeInternals<import("./v1/schema.js").RichNode, unknown>>]>>;
|
|
7
|
+
}, z.core.$strict>>;
|
|
8
|
+
export type SourcePosition = {
|
|
9
|
+
line: number;
|
|
10
|
+
character: number;
|
|
11
|
+
};
|
|
12
|
+
export type SourceRange = {
|
|
13
|
+
start: SourcePosition;
|
|
14
|
+
end: SourcePosition;
|
|
15
|
+
};
|
|
16
|
+
export type SourceSpan = {
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
};
|
|
20
|
+
export type Thought = {
|
|
21
|
+
key: string;
|
|
22
|
+
id?: string;
|
|
23
|
+
title: string;
|
|
24
|
+
markdown: string;
|
|
25
|
+
unresolved: boolean;
|
|
26
|
+
position?: {
|
|
27
|
+
x: number;
|
|
28
|
+
y: number;
|
|
29
|
+
};
|
|
30
|
+
path: string;
|
|
31
|
+
};
|
|
32
|
+
export type Connection = {
|
|
33
|
+
key: string;
|
|
34
|
+
from: string;
|
|
35
|
+
to: string;
|
|
36
|
+
kind: "branch" | "cross";
|
|
37
|
+
path: string;
|
|
38
|
+
};
|
|
39
|
+
export type SemanticGraph = {
|
|
40
|
+
nodes: Thought[];
|
|
41
|
+
edges: Connection[];
|
|
42
|
+
};
|
|
43
|
+
export type Binding = {
|
|
44
|
+
key: string;
|
|
45
|
+
kind: "definition" | "reference";
|
|
46
|
+
span: SourceSpan;
|
|
47
|
+
ownerKey?: string;
|
|
48
|
+
};
|
|
49
|
+
export type Diagnostic = {
|
|
50
|
+
message: string;
|
|
51
|
+
range: SourceRange;
|
|
52
|
+
};
|
|
53
|
+
export type DocumentSnapshot = {
|
|
54
|
+
uri: string;
|
|
55
|
+
revision: number;
|
|
56
|
+
analyzedRevision: number | null;
|
|
57
|
+
graphRevision: number | null;
|
|
58
|
+
valid: boolean;
|
|
59
|
+
pending: boolean;
|
|
60
|
+
graph: SemanticGraph | null;
|
|
61
|
+
diagnostics: Diagnostic[];
|
|
62
|
+
bindings: Binding[];
|
|
63
|
+
};
|
|
64
|
+
export type ContentPatch = {
|
|
65
|
+
summary?: string | null;
|
|
66
|
+
full_text?: string | null;
|
|
67
|
+
};
|
|
68
|
+
export type PreparedEdit = {
|
|
69
|
+
edits: {
|
|
70
|
+
range: SourceRange;
|
|
71
|
+
newText: string;
|
|
72
|
+
}[];
|
|
73
|
+
source: string;
|
|
74
|
+
};
|
|
75
|
+
export declare class ContentEditError extends Error {
|
|
76
|
+
readonly code: "stale-analysis" | "unknown-key" | "unsupported" | "invalid-edit";
|
|
77
|
+
constructor(code: "stale-analysis" | "unknown-key" | "unsupported" | "invalid-edit", message: string);
|
|
78
|
+
}
|
|
79
|
+
export declare function positionToOffset(source: string, position: SourcePosition): number;
|
|
80
|
+
export declare function offsetToPosition(source: string, offset: number): SourcePosition;
|
|
81
|
+
export declare class DocumentSession {
|
|
82
|
+
readonly uri: string;
|
|
83
|
+
private text;
|
|
84
|
+
private version;
|
|
85
|
+
private state;
|
|
86
|
+
private records;
|
|
87
|
+
private validSource;
|
|
88
|
+
private serial;
|
|
89
|
+
constructor(uri: string, source?: string, revision?: number);
|
|
90
|
+
get source(): string;
|
|
91
|
+
get revision(): number;
|
|
92
|
+
receive(source: string, revision: number): DocumentSnapshot;
|
|
93
|
+
update(source: string, revision: number): DocumentSnapshot;
|
|
94
|
+
snapshot(): DocumentSnapshot;
|
|
95
|
+
private range;
|
|
96
|
+
analyze(): DocumentSnapshot;
|
|
97
|
+
locate(position: SourcePosition): string | undefined;
|
|
98
|
+
definition(key: string): SourceRange | undefined;
|
|
99
|
+
highlights(position: SourcePosition): SourceRange[];
|
|
100
|
+
prepareContentEdit(key: string, patch: ContentPatch): PreparedEdit;
|
|
101
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { isAlias, isMap, isScalar, parseDocument } from "yaml";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { DocumentSchema, } from "./v1/schema.js";
|
|
4
|
+
export { DocumentSchema } from "./v1/schema.js";
|
|
5
|
+
export function generateJSONSchema() {
|
|
6
|
+
return z.toJSONSchema(DocumentSchema, { io: "input" });
|
|
7
|
+
}
|
|
8
|
+
export class ContentEditError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.name = "ContentEditError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function positionToOffset(source, position) {
|
|
17
|
+
let start = 0;
|
|
18
|
+
for (let line = 0; line < Math.max(0, position.line); line++) {
|
|
19
|
+
const end = source.indexOf("\n", start);
|
|
20
|
+
if (end < 0)
|
|
21
|
+
return source.length;
|
|
22
|
+
start = end + 1;
|
|
23
|
+
}
|
|
24
|
+
const newline = source.indexOf("\n", start);
|
|
25
|
+
let end = newline < 0 ? source.length : newline;
|
|
26
|
+
if (end > start && source[end - 1] === "\r")
|
|
27
|
+
end--;
|
|
28
|
+
return Math.min(end, start + Math.max(0, position.character));
|
|
29
|
+
}
|
|
30
|
+
export function offsetToPosition(source, offset) {
|
|
31
|
+
offset = Math.max(0, Math.min(source.length, offset));
|
|
32
|
+
const before = source.slice(0, offset);
|
|
33
|
+
const line = before.split("\n").length - 1;
|
|
34
|
+
return { line, character: offset - (before.lastIndexOf("\n") + 1) };
|
|
35
|
+
}
|
|
36
|
+
function rejectCycles(value, ancestors = new Set()) {
|
|
37
|
+
if (!value || typeof value !== "object")
|
|
38
|
+
return;
|
|
39
|
+
if (ancestors.has(value))
|
|
40
|
+
throw new Error("YAML alias cycles are not supported; use node_id for graph cycles.");
|
|
41
|
+
ancestors.add(value);
|
|
42
|
+
for (const child of Object.values(value))
|
|
43
|
+
rejectCycles(child, ancestors);
|
|
44
|
+
ancestors.delete(value);
|
|
45
|
+
}
|
|
46
|
+
export class DocumentSession {
|
|
47
|
+
uri;
|
|
48
|
+
text;
|
|
49
|
+
version;
|
|
50
|
+
state;
|
|
51
|
+
records = [];
|
|
52
|
+
validSource = "";
|
|
53
|
+
serial = 0;
|
|
54
|
+
constructor(uri, source = "", revision = 0) {
|
|
55
|
+
this.uri = uri;
|
|
56
|
+
this.text = source;
|
|
57
|
+
this.version = revision;
|
|
58
|
+
this.state = {
|
|
59
|
+
uri,
|
|
60
|
+
revision,
|
|
61
|
+
analyzedRevision: null,
|
|
62
|
+
graphRevision: null,
|
|
63
|
+
valid: false,
|
|
64
|
+
pending: true,
|
|
65
|
+
graph: null,
|
|
66
|
+
diagnostics: [],
|
|
67
|
+
bindings: [],
|
|
68
|
+
};
|
|
69
|
+
this.analyze();
|
|
70
|
+
}
|
|
71
|
+
get source() {
|
|
72
|
+
return this.text;
|
|
73
|
+
}
|
|
74
|
+
get revision() {
|
|
75
|
+
return this.version;
|
|
76
|
+
}
|
|
77
|
+
receive(source, revision) {
|
|
78
|
+
this.text = source;
|
|
79
|
+
this.version = revision;
|
|
80
|
+
this.state = {
|
|
81
|
+
...this.state,
|
|
82
|
+
revision,
|
|
83
|
+
valid: false,
|
|
84
|
+
pending: true,
|
|
85
|
+
bindings: [],
|
|
86
|
+
diagnostics: [],
|
|
87
|
+
};
|
|
88
|
+
return this.snapshot();
|
|
89
|
+
}
|
|
90
|
+
update(source, revision) {
|
|
91
|
+
this.receive(source, revision);
|
|
92
|
+
return this.analyze();
|
|
93
|
+
}
|
|
94
|
+
snapshot() {
|
|
95
|
+
return structuredClone(this.state);
|
|
96
|
+
}
|
|
97
|
+
range(span) {
|
|
98
|
+
return {
|
|
99
|
+
start: offsetToPosition(this.text, span.start),
|
|
100
|
+
end: offsetToPosition(this.text, span.end),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
analyze() {
|
|
104
|
+
try {
|
|
105
|
+
const doc = parseDocument(this.text, { uniqueKeys: true });
|
|
106
|
+
const problem = [...doc.errors, ...doc.warnings][0];
|
|
107
|
+
if (problem)
|
|
108
|
+
throw Object.assign(new Error(`YAML: ${problem.message}`), {
|
|
109
|
+
offset: problem.pos[0],
|
|
110
|
+
});
|
|
111
|
+
const input = doc.toJS();
|
|
112
|
+
rejectCycles(input);
|
|
113
|
+
const result = DocumentSchema.safeParse(input);
|
|
114
|
+
if (!result.success)
|
|
115
|
+
throw new Error(z.prettifyError(result.error));
|
|
116
|
+
const records = [];
|
|
117
|
+
const edges = [];
|
|
118
|
+
const bindings = [];
|
|
119
|
+
const refs = [];
|
|
120
|
+
const ids = new Map();
|
|
121
|
+
let prefix = 0;
|
|
122
|
+
while (prefix < this.validSource.length &&
|
|
123
|
+
prefix < this.text.length &&
|
|
124
|
+
this.validSource[prefix] === this.text[prefix])
|
|
125
|
+
prefix++;
|
|
126
|
+
let suffix = 0;
|
|
127
|
+
while (suffix < this.validSource.length - prefix &&
|
|
128
|
+
suffix < this.text.length - prefix &&
|
|
129
|
+
this.validSource.at(-suffix - 1) === this.text.at(-suffix - 1))
|
|
130
|
+
suffix++;
|
|
131
|
+
const mapped = (n) => n <= prefix
|
|
132
|
+
? n
|
|
133
|
+
: n >= this.validSource.length - suffix
|
|
134
|
+
? n + this.text.length - this.validSource.length
|
|
135
|
+
: -1;
|
|
136
|
+
const visit = (entry, path, parent) => {
|
|
137
|
+
const pathName = path.join(".");
|
|
138
|
+
let ast = doc.getIn(path, true);
|
|
139
|
+
if (!ast) {
|
|
140
|
+
for (let length = path.length - 1; length > 0; length--) {
|
|
141
|
+
const ancestor = doc.getIn(path.slice(0, length), true);
|
|
142
|
+
if (isAlias(ancestor)) {
|
|
143
|
+
ast = ancestor;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Descendants materialized from aliases point to that alias occurrence;
|
|
149
|
+
// edits through aliases are explicitly unsupported.
|
|
150
|
+
// Aliases are distinct occurrences with their own source span.
|
|
151
|
+
const span = { start: ast?.range?.[0] ?? 0, end: ast?.range?.[1] ?? 0 };
|
|
152
|
+
if (typeof entry === "object" &&
|
|
153
|
+
entry !== null &&
|
|
154
|
+
!Array.isArray(entry) &&
|
|
155
|
+
"node_id" in entry) {
|
|
156
|
+
if (parent)
|
|
157
|
+
refs.push({
|
|
158
|
+
from: parent,
|
|
159
|
+
id: entry.node_id,
|
|
160
|
+
path: pathName,
|
|
161
|
+
span,
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const content = typeof entry === "string"
|
|
166
|
+
? { full_text: entry }
|
|
167
|
+
: Array.isArray(entry)
|
|
168
|
+
? { full_text: null, connections: entry }
|
|
169
|
+
: entry;
|
|
170
|
+
const markdown = content.full_text ?? content.summary ?? "";
|
|
171
|
+
const title = content.summary !== undefined
|
|
172
|
+
? (content.summary ?? "")
|
|
173
|
+
: (content.full_text ?? "").split(/\r?\n/, 1)[0];
|
|
174
|
+
const key = `thought:${++this.serial}`;
|
|
175
|
+
const thought = {
|
|
176
|
+
key,
|
|
177
|
+
id: content.id,
|
|
178
|
+
title,
|
|
179
|
+
markdown,
|
|
180
|
+
unresolved: title.trim() === "",
|
|
181
|
+
position: content.position,
|
|
182
|
+
path: pathName,
|
|
183
|
+
};
|
|
184
|
+
records.push({ thought, span, ast });
|
|
185
|
+
bindings.push({ key, kind: "definition", span });
|
|
186
|
+
if (content.id !== undefined) {
|
|
187
|
+
if (ids.has(content.id))
|
|
188
|
+
throw new Error(`${pathName}.id: duplicate id "${content.id}".`);
|
|
189
|
+
ids.set(content.id, key);
|
|
190
|
+
}
|
|
191
|
+
if (parent)
|
|
192
|
+
edges.push({
|
|
193
|
+
key: `edge:${key}`,
|
|
194
|
+
from: parent,
|
|
195
|
+
to: key,
|
|
196
|
+
kind: "branch",
|
|
197
|
+
path: pathName,
|
|
198
|
+
});
|
|
199
|
+
content.connections?.forEach((child, i) => {
|
|
200
|
+
visit(child, Array.isArray(entry) ? [...path, i] : [...path, "connections", i], key);
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
result.data.nodes.forEach((entry, i) => {
|
|
204
|
+
visit(entry, ["nodes", i]);
|
|
205
|
+
});
|
|
206
|
+
// Reconcile the complete parse so newly inserted copies cannot steal
|
|
207
|
+
// identities from occurrences whose ranges still map to surviving text.
|
|
208
|
+
const remap = new Map();
|
|
209
|
+
const remaining = new Set(records);
|
|
210
|
+
const oldRemaining = new Set(this.records);
|
|
211
|
+
const assign = (old, current) => {
|
|
212
|
+
remap.set(current.thought.key, old.thought.key);
|
|
213
|
+
remaining.delete(current);
|
|
214
|
+
oldRemaining.delete(old);
|
|
215
|
+
};
|
|
216
|
+
for (const old of this.records) {
|
|
217
|
+
const candidates = [...remaining].filter((r) => isMap(old.ast) === isMap(r.ast) &&
|
|
218
|
+
isScalar(old.ast) === isScalar(r.ast) &&
|
|
219
|
+
mapped(old.span.end) === r.span.end);
|
|
220
|
+
if (candidates.length === 1)
|
|
221
|
+
assign(old, candidates[0]);
|
|
222
|
+
}
|
|
223
|
+
for (const old of [...oldRemaining]) {
|
|
224
|
+
const candidates = [...remaining].filter((r) => old.thought.id !== undefined
|
|
225
|
+
? r.thought.id === old.thought.id
|
|
226
|
+
: r.thought.title === old.thought.title &&
|
|
227
|
+
r.thought.markdown === old.thought.markdown);
|
|
228
|
+
const competing = [...oldRemaining].filter((r) => old.thought.id !== undefined
|
|
229
|
+
? r.thought.id === old.thought.id
|
|
230
|
+
: r.thought.title === old.thought.title &&
|
|
231
|
+
r.thought.markdown === old.thought.markdown);
|
|
232
|
+
if (candidates.length === 1 && competing.length === 1)
|
|
233
|
+
assign(old, candidates[0]);
|
|
234
|
+
}
|
|
235
|
+
for (const old of [...oldRemaining]) {
|
|
236
|
+
const candidates = [...remaining].filter((r) => isMap(old.ast) === isMap(r.ast) &&
|
|
237
|
+
isScalar(old.ast) === isScalar(r.ast) &&
|
|
238
|
+
mapped(old.span.start) === r.span.start);
|
|
239
|
+
if (candidates.length === 1)
|
|
240
|
+
assign(old, candidates[0]);
|
|
241
|
+
}
|
|
242
|
+
const stable = (key) => remap.get(key) ?? key;
|
|
243
|
+
for (const record of records)
|
|
244
|
+
record.thought.key = stable(record.thought.key);
|
|
245
|
+
for (const binding of bindings)
|
|
246
|
+
binding.key = stable(binding.key);
|
|
247
|
+
for (const edge of edges) {
|
|
248
|
+
edge.from = stable(edge.from);
|
|
249
|
+
edge.to = stable(edge.to);
|
|
250
|
+
edge.key = `edge:${edge.to}`;
|
|
251
|
+
}
|
|
252
|
+
for (const [id, key] of ids)
|
|
253
|
+
ids.set(id, stable(key));
|
|
254
|
+
for (const ref of refs)
|
|
255
|
+
ref.from = stable(ref.from);
|
|
256
|
+
const parallelReferences = new Map();
|
|
257
|
+
for (const ref of refs) {
|
|
258
|
+
const to = ids.get(ref.id);
|
|
259
|
+
if (!to)
|
|
260
|
+
throw new Error(`${ref.path}.node_id: no node has id "${ref.id}".`);
|
|
261
|
+
const endpoints = `${ref.from}:${to}`;
|
|
262
|
+
const occurrence = parallelReferences.get(endpoints) ?? 0;
|
|
263
|
+
parallelReferences.set(endpoints, occurrence + 1);
|
|
264
|
+
edges.push({
|
|
265
|
+
key: `cross:${endpoints}:${occurrence}`,
|
|
266
|
+
from: ref.from,
|
|
267
|
+
to,
|
|
268
|
+
kind: "cross",
|
|
269
|
+
path: ref.path,
|
|
270
|
+
});
|
|
271
|
+
bindings.push({
|
|
272
|
+
key: to,
|
|
273
|
+
kind: "reference",
|
|
274
|
+
span: ref.span,
|
|
275
|
+
ownerKey: ref.from,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
this.records = records;
|
|
279
|
+
this.validSource = this.text;
|
|
280
|
+
this.state = {
|
|
281
|
+
uri: this.uri,
|
|
282
|
+
revision: this.version,
|
|
283
|
+
analyzedRevision: this.version,
|
|
284
|
+
graphRevision: this.version,
|
|
285
|
+
valid: true,
|
|
286
|
+
pending: false,
|
|
287
|
+
graph: { nodes: records.map((r) => r.thought), edges },
|
|
288
|
+
diagnostics: [],
|
|
289
|
+
bindings,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
const offset = typeof error === "object" && error !== null && "offset" in error
|
|
294
|
+
? Number(error.offset)
|
|
295
|
+
: 0;
|
|
296
|
+
this.state = {
|
|
297
|
+
...this.state,
|
|
298
|
+
analyzedRevision: this.version,
|
|
299
|
+
valid: false,
|
|
300
|
+
pending: false,
|
|
301
|
+
bindings: [],
|
|
302
|
+
diagnostics: [
|
|
303
|
+
{
|
|
304
|
+
message: error instanceof Error ? error.message : String(error),
|
|
305
|
+
range: this.range({ start: offset, end: offset }),
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
return this.snapshot();
|
|
311
|
+
}
|
|
312
|
+
locate(position) {
|
|
313
|
+
if (!this.state.valid || this.state.pending)
|
|
314
|
+
return;
|
|
315
|
+
const offset = positionToOffset(this.text, position);
|
|
316
|
+
return this.state.bindings
|
|
317
|
+
.filter((b) => b.span.start <= offset && offset < b.span.end)
|
|
318
|
+
.sort((a, b) => a.span.end - a.span.start - (b.span.end - b.span.start))[0]?.key;
|
|
319
|
+
}
|
|
320
|
+
definition(key) {
|
|
321
|
+
if (!this.state.valid || this.state.pending)
|
|
322
|
+
return;
|
|
323
|
+
const b = this.state.bindings.find((b) => b.key === key && b.kind === "definition");
|
|
324
|
+
return b ? this.range(b.span) : undefined;
|
|
325
|
+
}
|
|
326
|
+
highlights(position) {
|
|
327
|
+
const key = this.locate(position);
|
|
328
|
+
return key
|
|
329
|
+
? this.state.bindings
|
|
330
|
+
.filter((b) => b.key === key)
|
|
331
|
+
.map((b) => this.range(b.span))
|
|
332
|
+
: [];
|
|
333
|
+
}
|
|
334
|
+
prepareContentEdit(key, patch) {
|
|
335
|
+
if (!this.state.valid || this.state.pending)
|
|
336
|
+
throw new ContentEditError("stale-analysis", "Current valid analysis is required.");
|
|
337
|
+
const record = this.records.find((r) => r.thought.key === key);
|
|
338
|
+
if (!record)
|
|
339
|
+
throw new ContentEditError("unknown-key", "Unknown thought key.");
|
|
340
|
+
const ast = record.ast;
|
|
341
|
+
const changes = [];
|
|
342
|
+
const unsupported = () => new ContentEditError("unsupported", "This source form cannot be edited safely.");
|
|
343
|
+
if (isAlias(ast))
|
|
344
|
+
throw unsupported();
|
|
345
|
+
const scalarText = (start, end, value) => {
|
|
346
|
+
const original = this.text.slice(start, end);
|
|
347
|
+
const comment = /^[|>][0-9+-]*[ \t]*(#[^\r\n]*)/.exec(original)?.[1];
|
|
348
|
+
return (value +
|
|
349
|
+
(comment ? ` ${comment}` : "") +
|
|
350
|
+
(original.endsWith("\r\n")
|
|
351
|
+
? "\r\n"
|
|
352
|
+
: original.endsWith("\n")
|
|
353
|
+
? "\n"
|
|
354
|
+
: ""));
|
|
355
|
+
};
|
|
356
|
+
const fields = ["summary", "full_text"].filter((field) => patch[field] !== undefined);
|
|
357
|
+
if (isScalar(ast)) {
|
|
358
|
+
if (fields.includes("summary"))
|
|
359
|
+
throw unsupported();
|
|
360
|
+
if (fields.includes("full_text")) {
|
|
361
|
+
if (patch.full_text === null)
|
|
362
|
+
throw unsupported();
|
|
363
|
+
changes.push({
|
|
364
|
+
...record.span,
|
|
365
|
+
newText: scalarText(record.span.start, record.span.end, JSON.stringify(patch.full_text)),
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
else if (isMap(ast)) {
|
|
370
|
+
for (const field of fields) {
|
|
371
|
+
const value = ast.get(field, true);
|
|
372
|
+
const newText = JSON.stringify(patch[field]);
|
|
373
|
+
if (value && isScalar(value) && value.range) {
|
|
374
|
+
changes.push({
|
|
375
|
+
start: value.range[0],
|
|
376
|
+
end: value.range[1],
|
|
377
|
+
newText: scalarText(value.range[0], value.range[1], newText),
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
else if (value !== undefined)
|
|
381
|
+
throw unsupported();
|
|
382
|
+
else {
|
|
383
|
+
if (ast.flow)
|
|
384
|
+
throw unsupported();
|
|
385
|
+
const first = ast.items[0]?.key;
|
|
386
|
+
if (!isScalar(first) || !first.range)
|
|
387
|
+
throw unsupported();
|
|
388
|
+
const start = first.range[0];
|
|
389
|
+
const lineStart = this.text.lastIndexOf("\n", start - 1) + 1;
|
|
390
|
+
// Insert before the first key, retaining the existing sequence marker.
|
|
391
|
+
const indent = " ".repeat(start - lineStart);
|
|
392
|
+
changes.push({
|
|
393
|
+
start,
|
|
394
|
+
end: start,
|
|
395
|
+
newText: `${field}: ${newText}\n${indent}`,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
else if (fields.length)
|
|
401
|
+
throw unsupported();
|
|
402
|
+
let source = this.text;
|
|
403
|
+
for (const change of [...changes].sort((a, b) => b.start - a.start))
|
|
404
|
+
source =
|
|
405
|
+
source.slice(0, change.start) +
|
|
406
|
+
change.newText +
|
|
407
|
+
source.slice(change.end);
|
|
408
|
+
const candidate = new DocumentSession(this.uri, source, this.version);
|
|
409
|
+
if (!candidate.snapshot().valid)
|
|
410
|
+
throw new ContentEditError("invalid-edit", "Proposed content does not form a valid document.");
|
|
411
|
+
return {
|
|
412
|
+
source,
|
|
413
|
+
edits: changes.map((c) => ({ range: this.range(c), newText: c.newText })),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Experimental v1 subset. Fields may be added or refined before v1 stabilizes. */
|
|
3
|
+
export declare const PositionSchema: z.ZodObject<{
|
|
4
|
+
x: z.ZodNumber;
|
|
5
|
+
y: z.ZodNumber;
|
|
6
|
+
}, z.core.$strict>;
|
|
7
|
+
export declare const ReferenceSchema: z.ZodObject<{
|
|
8
|
+
node_id: z.ZodString;
|
|
9
|
+
}, z.core.$strict>;
|
|
10
|
+
declare const NodeContentSchema: z.ZodObject<{
|
|
11
|
+
id: z.ZodOptional<z.ZodString>;
|
|
12
|
+
summary: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
13
|
+
full_text: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
14
|
+
position: z.ZodOptional<z.ZodObject<{
|
|
15
|
+
x: z.ZodNumber;
|
|
16
|
+
y: z.ZodNumber;
|
|
17
|
+
}, z.core.$strict>>;
|
|
18
|
+
}, z.core.$strict>;
|
|
19
|
+
export type Reference = z.infer<typeof ReferenceSchema>;
|
|
20
|
+
export type RichNode = z.infer<typeof NodeContentSchema> & {
|
|
21
|
+
connections?: Connection[];
|
|
22
|
+
};
|
|
23
|
+
export type Connection = string | RichNode | Reference | Connection[];
|
|
24
|
+
export declare const RichNodeSchema: z.ZodType<RichNode>;
|
|
25
|
+
export declare const ConnectionSchema: z.ZodType<Connection>;
|
|
26
|
+
export declare const NodeSchema: z.ZodUnion<readonly [z.ZodString, z.ZodType<RichNode, unknown, z.core.$ZodTypeInternals<RichNode, unknown>>]>;
|
|
27
|
+
export declare const DocumentSchema: z.ZodObject<{
|
|
28
|
+
version: z.ZodLiteral<"v1">;
|
|
29
|
+
nodes: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodType<RichNode, unknown, z.core.$ZodTypeInternals<RichNode, unknown>>]>>;
|
|
30
|
+
}, z.core.$strict>;
|
|
31
|
+
export type LensmapDocument = z.infer<typeof DocumentSchema>;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Experimental v1 subset. Fields may be added or refined before v1 stabilizes. */
|
|
3
|
+
export const PositionSchema = z.strictObject({
|
|
4
|
+
x: z.number().finite(),
|
|
5
|
+
y: z.number().finite(),
|
|
6
|
+
});
|
|
7
|
+
const IdSchema = z.string().regex(/\S/, "An ID must not be empty.");
|
|
8
|
+
export const ReferenceSchema = z.strictObject({ node_id: IdSchema });
|
|
9
|
+
const NodeContentSchema = z.strictObject({
|
|
10
|
+
id: IdSchema.optional(),
|
|
11
|
+
summary: z.string().nullable().optional(),
|
|
12
|
+
full_text: z.string().nullable().optional(),
|
|
13
|
+
position: PositionSchema.optional(),
|
|
14
|
+
});
|
|
15
|
+
export const RichNodeSchema = z.lazy(() => z.union([
|
|
16
|
+
NodeContentSchema.extend({
|
|
17
|
+
summary: z.string().nullable(),
|
|
18
|
+
connections: z.array(ConnectionSchema).optional(),
|
|
19
|
+
}),
|
|
20
|
+
NodeContentSchema.extend({
|
|
21
|
+
full_text: z.string().nullable(),
|
|
22
|
+
connections: z.array(ConnectionSchema).optional(),
|
|
23
|
+
}),
|
|
24
|
+
]));
|
|
25
|
+
export const ConnectionSchema = z.lazy(() => z.union([
|
|
26
|
+
z.string(),
|
|
27
|
+
ReferenceSchema,
|
|
28
|
+
RichNodeSchema,
|
|
29
|
+
z.array(ConnectionSchema),
|
|
30
|
+
]));
|
|
31
|
+
export const NodeSchema = z.union([z.string(), RichNodeSchema]);
|
|
32
|
+
export const DocumentSchema = z.strictObject({
|
|
33
|
+
version: z.literal("v1"),
|
|
34
|
+
nodes: z.array(NodeSchema),
|
|
35
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lensmap/document",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"license": "MIT",
|
|
4
|
+
"description": "Experimental Lensmap document engine: YAML analysis, semantic graphs and revision-bound source edits",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public",
|
|
11
|
+
"tag": "next",
|
|
12
|
+
"registry": "https://registry.npmjs.org/"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/nicodoggie/lensmap.git",
|
|
17
|
+
"directory": "packages/document"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=24.16.0"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./v1": {
|
|
29
|
+
"types": "./dist/v1/schema.d.ts",
|
|
30
|
+
"default": "./dist/v1/schema.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"yaml": "2.9.1",
|
|
35
|
+
"zod": "4.6.7"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/node": "24.19.2",
|
|
39
|
+
"typescript": "7.0.2",
|
|
40
|
+
"vitest": "5.1.0",
|
|
41
|
+
"ajv": "8.20.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "vitest run"
|
|
47
|
+
}
|
|
6
48
|
}
|