@lensmap/document 0.0.0-stage → 0.1.1
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 +119 -0
- package/dist/index.js +501 -0
- package/dist/v1/schema.d.ts +53 -0
- package/dist/v1/schema.js +66 -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,119 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type ConnectionType } from "./v1/schema.js";
|
|
3
|
+
export type { ConnectionType, LensmapDocument, Reference, RichNode, } from "./v1/schema.js";
|
|
4
|
+
export { ConnectionTypeSchema, DocumentSchema } from "./v1/schema.js";
|
|
5
|
+
export declare function generateJSONSchema(): z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
|
|
6
|
+
version: z.ZodLiteral<"v1">;
|
|
7
|
+
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>>]>>;
|
|
8
|
+
}, z.core.$strict>>;
|
|
9
|
+
/** Read the edge from the inspected endpoint, independently of structural kind. */
|
|
10
|
+
export declare function connectionWording(type: ConnectionType | undefined, direction: "outgoing" | "incoming"): string;
|
|
11
|
+
type AnchorThought = {
|
|
12
|
+
readonly key: string;
|
|
13
|
+
readonly id?: string;
|
|
14
|
+
};
|
|
15
|
+
/** Generated spelling is private to this rendered document version. */
|
|
16
|
+
export declare function thoughtAnchors(nodes: readonly AnchorThought[]): Map<string, string>;
|
|
17
|
+
/** The host decodes the fragment once before calling; authored names stay exact. */
|
|
18
|
+
export declare function resolveThoughtAnchor(nodes: readonly AnchorThought[], decodedAnchor: string): string | undefined;
|
|
19
|
+
export type SourcePosition = {
|
|
20
|
+
line: number;
|
|
21
|
+
character: number;
|
|
22
|
+
};
|
|
23
|
+
export type SourceRange = {
|
|
24
|
+
start: SourcePosition;
|
|
25
|
+
end: SourcePosition;
|
|
26
|
+
};
|
|
27
|
+
export type SourceSpan = {
|
|
28
|
+
start: number;
|
|
29
|
+
end: number;
|
|
30
|
+
};
|
|
31
|
+
export type Thought = {
|
|
32
|
+
key: string;
|
|
33
|
+
id?: string;
|
|
34
|
+
title: string;
|
|
35
|
+
markdown: string;
|
|
36
|
+
unresolved: boolean;
|
|
37
|
+
position?: {
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
};
|
|
41
|
+
path: string;
|
|
42
|
+
destination?: {
|
|
43
|
+
kind: "map_link" | "external_link";
|
|
44
|
+
url: string;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
export type Connection = {
|
|
48
|
+
key: string;
|
|
49
|
+
from: string;
|
|
50
|
+
to: string;
|
|
51
|
+
kind: "branch" | "cross";
|
|
52
|
+
type?: ConnectionType;
|
|
53
|
+
path: string;
|
|
54
|
+
};
|
|
55
|
+
export type SemanticGraph = {
|
|
56
|
+
nodes: Thought[];
|
|
57
|
+
edges: Connection[];
|
|
58
|
+
};
|
|
59
|
+
export type Binding = {
|
|
60
|
+
key: string;
|
|
61
|
+
kind: "definition" | "reference";
|
|
62
|
+
span: SourceSpan;
|
|
63
|
+
ownerKey?: string;
|
|
64
|
+
};
|
|
65
|
+
export type Diagnostic = {
|
|
66
|
+
message: string;
|
|
67
|
+
range: SourceRange;
|
|
68
|
+
};
|
|
69
|
+
export type DocumentSnapshot = {
|
|
70
|
+
attachmentId?: string;
|
|
71
|
+
uri: string;
|
|
72
|
+
revision: number;
|
|
73
|
+
analyzedRevision: number | null;
|
|
74
|
+
graphRevision: number | null;
|
|
75
|
+
valid: boolean;
|
|
76
|
+
pending: boolean;
|
|
77
|
+
graph: SemanticGraph | null;
|
|
78
|
+
diagnostics: Diagnostic[];
|
|
79
|
+
bindings: Binding[];
|
|
80
|
+
};
|
|
81
|
+
export type ContentPatch = {
|
|
82
|
+
summary?: string | null;
|
|
83
|
+
full_text?: string | null;
|
|
84
|
+
};
|
|
85
|
+
export type PreparedEdit = {
|
|
86
|
+
edits: {
|
|
87
|
+
range: SourceRange;
|
|
88
|
+
newText: string;
|
|
89
|
+
}[];
|
|
90
|
+
source: string;
|
|
91
|
+
};
|
|
92
|
+
export declare class ContentEditError extends Error {
|
|
93
|
+
readonly code: "stale-analysis" | "unknown-key" | "unsupported" | "invalid-edit";
|
|
94
|
+
constructor(code: "stale-analysis" | "unknown-key" | "unsupported" | "invalid-edit", message: string);
|
|
95
|
+
}
|
|
96
|
+
export declare function positionToOffset(source: string, position: SourcePosition): number;
|
|
97
|
+
export declare function offsetToPosition(source: string, offset: number): SourcePosition;
|
|
98
|
+
export declare class DocumentSession {
|
|
99
|
+
readonly uri: string;
|
|
100
|
+
private readonly attachmentId;
|
|
101
|
+
private text;
|
|
102
|
+
private version;
|
|
103
|
+
private state;
|
|
104
|
+
private records;
|
|
105
|
+
private validSource;
|
|
106
|
+
private serial;
|
|
107
|
+
constructor(uri: string, source?: string, revision?: number);
|
|
108
|
+
get source(): string;
|
|
109
|
+
get revision(): number;
|
|
110
|
+
receive(source: string, revision: number): DocumentSnapshot;
|
|
111
|
+
update(source: string, revision: number): DocumentSnapshot;
|
|
112
|
+
snapshot(): DocumentSnapshot;
|
|
113
|
+
private range;
|
|
114
|
+
analyze(): DocumentSnapshot;
|
|
115
|
+
locate(position: SourcePosition): string | undefined;
|
|
116
|
+
definition(key: string): SourceRange | undefined;
|
|
117
|
+
highlights(position: SourcePosition): SourceRange[];
|
|
118
|
+
prepareContentEdit(key: string, patch: ContentPatch): PreparedEdit;
|
|
119
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import { isAlias, isMap, isScalar, parseDocument } from "yaml";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { DocumentSchema, } from "./v1/schema.js";
|
|
4
|
+
export { ConnectionTypeSchema, DocumentSchema } from "./v1/schema.js";
|
|
5
|
+
export function generateJSONSchema() {
|
|
6
|
+
return z.toJSONSchema(DocumentSchema, { io: "input" });
|
|
7
|
+
}
|
|
8
|
+
/** Read the edge from the inspected endpoint, independently of structural kind. */
|
|
9
|
+
export function connectionWording(type, direction) {
|
|
10
|
+
switch (type) {
|
|
11
|
+
case "precedes":
|
|
12
|
+
return direction === "outgoing" ? "precedes" : "follows";
|
|
13
|
+
case "follows":
|
|
14
|
+
return direction === "outgoing" ? "follows" : "precedes";
|
|
15
|
+
case "prevents":
|
|
16
|
+
return direction === "outgoing" ? "prevents" : "is prevented by";
|
|
17
|
+
case "optional":
|
|
18
|
+
return "optionally connected to";
|
|
19
|
+
case "map_link":
|
|
20
|
+
return direction === "outgoing" ? "links to map" : "map linked from";
|
|
21
|
+
case "external_link":
|
|
22
|
+
return direction === "outgoing"
|
|
23
|
+
? "links to resource"
|
|
24
|
+
: "resource linked from";
|
|
25
|
+
default:
|
|
26
|
+
return "connected to";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Generated spelling is private to this rendered document version. */
|
|
30
|
+
export function thoughtAnchors(nodes) {
|
|
31
|
+
const reserved = new Set(nodes.flatMap((node) => (node.id === undefined ? [] : [node.id])));
|
|
32
|
+
const anchors = new Map();
|
|
33
|
+
for (const node of nodes) {
|
|
34
|
+
if (node.id !== undefined) {
|
|
35
|
+
anchors.set(node.key, node.id);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const base = `thought-${node.key}`;
|
|
39
|
+
let anchor = base;
|
|
40
|
+
let suffix = 0;
|
|
41
|
+
while (reserved.has(anchor))
|
|
42
|
+
anchor = `${base}-${++suffix}`;
|
|
43
|
+
reserved.add(anchor);
|
|
44
|
+
anchors.set(node.key, anchor);
|
|
45
|
+
}
|
|
46
|
+
return anchors;
|
|
47
|
+
}
|
|
48
|
+
/** The host decodes the fragment once before calling; authored names stay exact. */
|
|
49
|
+
export function resolveThoughtAnchor(nodes, decodedAnchor) {
|
|
50
|
+
for (const [key, anchor] of thoughtAnchors(nodes))
|
|
51
|
+
if (anchor === decodedAnchor)
|
|
52
|
+
return key;
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
let attachmentSerial = 0;
|
|
56
|
+
export class ContentEditError extends Error {
|
|
57
|
+
code;
|
|
58
|
+
constructor(code, message) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.code = code;
|
|
61
|
+
this.name = "ContentEditError";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function positionToOffset(source, position) {
|
|
65
|
+
let start = 0;
|
|
66
|
+
for (let line = 0; line < Math.max(0, position.line); line++) {
|
|
67
|
+
const end = source.indexOf("\n", start);
|
|
68
|
+
if (end < 0)
|
|
69
|
+
return source.length;
|
|
70
|
+
start = end + 1;
|
|
71
|
+
}
|
|
72
|
+
const newline = source.indexOf("\n", start);
|
|
73
|
+
let end = newline < 0 ? source.length : newline;
|
|
74
|
+
if (end > start && source[end - 1] === "\r")
|
|
75
|
+
end--;
|
|
76
|
+
return Math.min(end, start + Math.max(0, position.character));
|
|
77
|
+
}
|
|
78
|
+
export function offsetToPosition(source, offset) {
|
|
79
|
+
offset = Math.max(0, Math.min(source.length, offset));
|
|
80
|
+
const before = source.slice(0, offset);
|
|
81
|
+
const line = before.split("\n").length - 1;
|
|
82
|
+
return { line, character: offset - (before.lastIndexOf("\n") + 1) };
|
|
83
|
+
}
|
|
84
|
+
function rejectCycles(value, ancestors = new Set()) {
|
|
85
|
+
if (!value || typeof value !== "object")
|
|
86
|
+
return;
|
|
87
|
+
if (ancestors.has(value))
|
|
88
|
+
throw new Error("YAML alias cycles are not supported; use node_id for graph cycles.");
|
|
89
|
+
ancestors.add(value);
|
|
90
|
+
for (const child of Object.values(value))
|
|
91
|
+
rejectCycles(child, ancestors);
|
|
92
|
+
ancestors.delete(value);
|
|
93
|
+
}
|
|
94
|
+
export class DocumentSession {
|
|
95
|
+
uri;
|
|
96
|
+
attachmentId = `attachment:${++attachmentSerial}`;
|
|
97
|
+
text;
|
|
98
|
+
version;
|
|
99
|
+
state;
|
|
100
|
+
records = [];
|
|
101
|
+
validSource = "";
|
|
102
|
+
serial = 0;
|
|
103
|
+
constructor(uri, source = "", revision = 0) {
|
|
104
|
+
this.uri = uri;
|
|
105
|
+
this.text = source;
|
|
106
|
+
this.version = revision;
|
|
107
|
+
this.state = {
|
|
108
|
+
uri,
|
|
109
|
+
attachmentId: this.attachmentId,
|
|
110
|
+
revision,
|
|
111
|
+
analyzedRevision: null,
|
|
112
|
+
graphRevision: null,
|
|
113
|
+
valid: false,
|
|
114
|
+
pending: true,
|
|
115
|
+
graph: null,
|
|
116
|
+
diagnostics: [],
|
|
117
|
+
bindings: [],
|
|
118
|
+
};
|
|
119
|
+
this.analyze();
|
|
120
|
+
}
|
|
121
|
+
get source() {
|
|
122
|
+
return this.text;
|
|
123
|
+
}
|
|
124
|
+
get revision() {
|
|
125
|
+
return this.version;
|
|
126
|
+
}
|
|
127
|
+
receive(source, revision) {
|
|
128
|
+
this.text = source;
|
|
129
|
+
this.version = revision;
|
|
130
|
+
this.state = {
|
|
131
|
+
...this.state,
|
|
132
|
+
revision,
|
|
133
|
+
valid: false,
|
|
134
|
+
pending: true,
|
|
135
|
+
bindings: [],
|
|
136
|
+
diagnostics: [],
|
|
137
|
+
};
|
|
138
|
+
return this.snapshot();
|
|
139
|
+
}
|
|
140
|
+
update(source, revision) {
|
|
141
|
+
this.receive(source, revision);
|
|
142
|
+
return this.analyze();
|
|
143
|
+
}
|
|
144
|
+
snapshot() {
|
|
145
|
+
return structuredClone(this.state);
|
|
146
|
+
}
|
|
147
|
+
range(span) {
|
|
148
|
+
return {
|
|
149
|
+
start: offsetToPosition(this.text, span.start),
|
|
150
|
+
end: offsetToPosition(this.text, span.end),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
analyze() {
|
|
154
|
+
try {
|
|
155
|
+
const doc = parseDocument(this.text, { uniqueKeys: true });
|
|
156
|
+
const problem = [...doc.errors, ...doc.warnings][0];
|
|
157
|
+
if (problem)
|
|
158
|
+
throw Object.assign(new Error(`YAML: ${problem.message}`), {
|
|
159
|
+
offset: problem.pos[0],
|
|
160
|
+
});
|
|
161
|
+
const input = doc.toJS();
|
|
162
|
+
rejectCycles(input);
|
|
163
|
+
const result = DocumentSchema.safeParse(input);
|
|
164
|
+
if (!result.success) {
|
|
165
|
+
// Union alternatives report their paths at the union boundary. Locate
|
|
166
|
+
// a concrete failing field in the source, not an unrelated line zero.
|
|
167
|
+
const leaves = (issues, prefix = []) => issues.flatMap((issue) => {
|
|
168
|
+
const path = [...prefix, ...issue.path];
|
|
169
|
+
return issue.code === "invalid_union"
|
|
170
|
+
? leaves(issue.errors.flat(), path)
|
|
171
|
+
: [{ ...issue, path }];
|
|
172
|
+
});
|
|
173
|
+
const located = leaves(result.error.issues)
|
|
174
|
+
.map((issue) => {
|
|
175
|
+
const path = issue.path.filter((part) => typeof part !== "symbol");
|
|
176
|
+
if (issue.code === "unrecognized_keys" && issue.keys[0])
|
|
177
|
+
path.push(issue.keys[0]);
|
|
178
|
+
const ast = doc.getIn(path, true);
|
|
179
|
+
return { issue, path, offset: ast?.range?.[0] };
|
|
180
|
+
})
|
|
181
|
+
.filter((item) => item.offset !== undefined)
|
|
182
|
+
.sort((a, b) => b.path.length - a.path.length)[0];
|
|
183
|
+
throw Object.assign(new Error(located?.issue.message ?? z.prettifyError(result.error)), { offset: located?.offset ?? 0 });
|
|
184
|
+
}
|
|
185
|
+
const records = [];
|
|
186
|
+
const edges = [];
|
|
187
|
+
const bindings = [];
|
|
188
|
+
const refs = [];
|
|
189
|
+
const ids = new Map();
|
|
190
|
+
let prefix = 0;
|
|
191
|
+
while (prefix < this.validSource.length &&
|
|
192
|
+
prefix < this.text.length &&
|
|
193
|
+
this.validSource[prefix] === this.text[prefix])
|
|
194
|
+
prefix++;
|
|
195
|
+
let suffix = 0;
|
|
196
|
+
while (suffix < this.validSource.length - prefix &&
|
|
197
|
+
suffix < this.text.length - prefix &&
|
|
198
|
+
this.validSource.at(-suffix - 1) === this.text.at(-suffix - 1))
|
|
199
|
+
suffix++;
|
|
200
|
+
const mapped = (n) => n <= prefix
|
|
201
|
+
? n
|
|
202
|
+
: n >= this.validSource.length - suffix
|
|
203
|
+
? n + this.text.length - this.validSource.length
|
|
204
|
+
: -1;
|
|
205
|
+
const visit = (entry, path, parent) => {
|
|
206
|
+
const pathName = path.join(".");
|
|
207
|
+
let ast = doc.getIn(path, true);
|
|
208
|
+
if (!ast) {
|
|
209
|
+
for (let length = path.length - 1; length > 0; length--) {
|
|
210
|
+
const ancestor = doc.getIn(path.slice(0, length), true);
|
|
211
|
+
if (isAlias(ancestor)) {
|
|
212
|
+
ast = ancestor;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Descendants materialized from aliases point to that alias occurrence;
|
|
218
|
+
// edits through aliases are explicitly unsupported.
|
|
219
|
+
// Aliases are distinct occurrences with their own source span.
|
|
220
|
+
const span = { start: ast?.range?.[0] ?? 0, end: ast?.range?.[1] ?? 0 };
|
|
221
|
+
if (typeof entry === "object" &&
|
|
222
|
+
entry !== null &&
|
|
223
|
+
!Array.isArray(entry) &&
|
|
224
|
+
"node_id" in entry) {
|
|
225
|
+
if (parent)
|
|
226
|
+
refs.push({
|
|
227
|
+
from: parent,
|
|
228
|
+
id: entry.node_id,
|
|
229
|
+
type: entry.type,
|
|
230
|
+
path: pathName,
|
|
231
|
+
span,
|
|
232
|
+
});
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const content = typeof entry === "string"
|
|
236
|
+
? { full_text: entry }
|
|
237
|
+
: Array.isArray(entry)
|
|
238
|
+
? { full_text: null, connections: entry }
|
|
239
|
+
: entry;
|
|
240
|
+
const destination = (content.type === "map_link" || content.type === "external_link") &&
|
|
241
|
+
content.url !== undefined
|
|
242
|
+
? { kind: content.type, url: content.url }
|
|
243
|
+
: undefined;
|
|
244
|
+
const fallback = content.summary === undefined && content.full_text === undefined
|
|
245
|
+
? (destination?.url ?? "")
|
|
246
|
+
: "";
|
|
247
|
+
const markdown = content.full_text ?? content.summary ?? fallback;
|
|
248
|
+
const title = content.summary !== undefined
|
|
249
|
+
? (content.summary ?? "")
|
|
250
|
+
: (content.full_text ?? fallback).split(/\r?\n/, 1)[0];
|
|
251
|
+
const key = `thought:${++this.serial}`;
|
|
252
|
+
const thought = {
|
|
253
|
+
key,
|
|
254
|
+
id: content.id,
|
|
255
|
+
title,
|
|
256
|
+
markdown,
|
|
257
|
+
unresolved: title.trim() === "",
|
|
258
|
+
position: content.position,
|
|
259
|
+
path: pathName,
|
|
260
|
+
...(destination ? { destination } : {}),
|
|
261
|
+
};
|
|
262
|
+
records.push({ thought, span, ast });
|
|
263
|
+
bindings.push({ key, kind: "definition", span });
|
|
264
|
+
if (content.id !== undefined) {
|
|
265
|
+
if (ids.has(content.id))
|
|
266
|
+
throw new Error(`${pathName}.id: duplicate id "${content.id}".`);
|
|
267
|
+
ids.set(content.id, key);
|
|
268
|
+
}
|
|
269
|
+
if (parent)
|
|
270
|
+
edges.push({
|
|
271
|
+
key: `edge:${key}`,
|
|
272
|
+
from: parent,
|
|
273
|
+
to: key,
|
|
274
|
+
kind: "branch",
|
|
275
|
+
...(content.type !== undefined ? { type: content.type } : {}),
|
|
276
|
+
path: pathName,
|
|
277
|
+
});
|
|
278
|
+
content.connections?.forEach((child, i) => {
|
|
279
|
+
visit(child, Array.isArray(entry) ? [...path, i] : [...path, "connections", i], key);
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
result.data.nodes.forEach((entry, i) => {
|
|
283
|
+
visit(entry, ["nodes", i]);
|
|
284
|
+
});
|
|
285
|
+
// Reconcile the complete parse so newly inserted copies cannot steal
|
|
286
|
+
// identities from occurrences whose ranges still map to surviving text.
|
|
287
|
+
const remap = new Map();
|
|
288
|
+
const remaining = new Set(records);
|
|
289
|
+
const oldRemaining = new Set(this.records);
|
|
290
|
+
const assign = (old, current) => {
|
|
291
|
+
remap.set(current.thought.key, old.thought.key);
|
|
292
|
+
remaining.delete(current);
|
|
293
|
+
oldRemaining.delete(old);
|
|
294
|
+
};
|
|
295
|
+
for (const old of this.records) {
|
|
296
|
+
const candidates = [...remaining].filter((r) => isMap(old.ast) === isMap(r.ast) &&
|
|
297
|
+
isScalar(old.ast) === isScalar(r.ast) &&
|
|
298
|
+
mapped(old.span.end) === r.span.end);
|
|
299
|
+
if (candidates.length === 1)
|
|
300
|
+
assign(old, candidates[0]);
|
|
301
|
+
}
|
|
302
|
+
for (const old of [...oldRemaining]) {
|
|
303
|
+
const candidates = [...remaining].filter((r) => old.thought.id !== undefined
|
|
304
|
+
? r.thought.id === old.thought.id
|
|
305
|
+
: r.thought.title === old.thought.title &&
|
|
306
|
+
r.thought.markdown === old.thought.markdown);
|
|
307
|
+
const competing = [...oldRemaining].filter((r) => old.thought.id !== undefined
|
|
308
|
+
? r.thought.id === old.thought.id
|
|
309
|
+
: r.thought.title === old.thought.title &&
|
|
310
|
+
r.thought.markdown === old.thought.markdown);
|
|
311
|
+
if (candidates.length === 1 && competing.length === 1)
|
|
312
|
+
assign(old, candidates[0]);
|
|
313
|
+
}
|
|
314
|
+
for (const old of [...oldRemaining]) {
|
|
315
|
+
const candidates = [...remaining].filter((r) => isMap(old.ast) === isMap(r.ast) &&
|
|
316
|
+
isScalar(old.ast) === isScalar(r.ast) &&
|
|
317
|
+
mapped(old.span.start) === r.span.start);
|
|
318
|
+
if (candidates.length === 1)
|
|
319
|
+
assign(old, candidates[0]);
|
|
320
|
+
}
|
|
321
|
+
const stable = (key) => remap.get(key) ?? key;
|
|
322
|
+
for (const record of records)
|
|
323
|
+
record.thought.key = stable(record.thought.key);
|
|
324
|
+
for (const binding of bindings)
|
|
325
|
+
binding.key = stable(binding.key);
|
|
326
|
+
for (const edge of edges) {
|
|
327
|
+
edge.from = stable(edge.from);
|
|
328
|
+
edge.to = stable(edge.to);
|
|
329
|
+
edge.key = `edge:${edge.to}`;
|
|
330
|
+
}
|
|
331
|
+
for (const [id, key] of ids)
|
|
332
|
+
ids.set(id, stable(key));
|
|
333
|
+
for (const ref of refs)
|
|
334
|
+
ref.from = stable(ref.from);
|
|
335
|
+
const parallelReferences = new Map();
|
|
336
|
+
for (const ref of refs) {
|
|
337
|
+
const to = ids.get(ref.id);
|
|
338
|
+
if (!to)
|
|
339
|
+
throw Object.assign(new Error(`${ref.path}.node_id: no node has id "${ref.id}".`), { offset: ref.span.start });
|
|
340
|
+
if ((ref.type === "map_link" || ref.type === "external_link") &&
|
|
341
|
+
records.find((r) => r.thought.key === to)?.thought.destination
|
|
342
|
+
?.kind !== ref.type)
|
|
343
|
+
throw Object.assign(new Error(`${ref.path}.type: reference requires a ${ref.type} destination.`), { offset: ref.span.start });
|
|
344
|
+
const endpoints = `${ref.from}:${to}`;
|
|
345
|
+
const occurrence = parallelReferences.get(endpoints) ?? 0;
|
|
346
|
+
parallelReferences.set(endpoints, occurrence + 1);
|
|
347
|
+
edges.push({
|
|
348
|
+
key: `cross:${endpoints}:${occurrence}`,
|
|
349
|
+
from: ref.from,
|
|
350
|
+
to,
|
|
351
|
+
kind: "cross",
|
|
352
|
+
...(ref.type !== undefined ? { type: ref.type } : {}),
|
|
353
|
+
path: ref.path,
|
|
354
|
+
});
|
|
355
|
+
bindings.push({
|
|
356
|
+
key: to,
|
|
357
|
+
kind: "reference",
|
|
358
|
+
span: ref.span,
|
|
359
|
+
ownerKey: ref.from,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
this.records = records;
|
|
363
|
+
this.validSource = this.text;
|
|
364
|
+
this.state = {
|
|
365
|
+
uri: this.uri,
|
|
366
|
+
attachmentId: this.attachmentId,
|
|
367
|
+
revision: this.version,
|
|
368
|
+
analyzedRevision: this.version,
|
|
369
|
+
graphRevision: this.version,
|
|
370
|
+
valid: true,
|
|
371
|
+
pending: false,
|
|
372
|
+
graph: { nodes: records.map((r) => r.thought), edges },
|
|
373
|
+
diagnostics: [],
|
|
374
|
+
bindings,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
const offset = typeof error === "object" && error !== null && "offset" in error
|
|
379
|
+
? Number(error.offset)
|
|
380
|
+
: 0;
|
|
381
|
+
this.state = {
|
|
382
|
+
...this.state,
|
|
383
|
+
analyzedRevision: this.version,
|
|
384
|
+
valid: false,
|
|
385
|
+
pending: false,
|
|
386
|
+
bindings: [],
|
|
387
|
+
diagnostics: [
|
|
388
|
+
{
|
|
389
|
+
message: error instanceof Error ? error.message : String(error),
|
|
390
|
+
range: this.range({ start: offset, end: offset }),
|
|
391
|
+
},
|
|
392
|
+
],
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
return this.snapshot();
|
|
396
|
+
}
|
|
397
|
+
locate(position) {
|
|
398
|
+
if (!this.state.valid || this.state.pending)
|
|
399
|
+
return;
|
|
400
|
+
const offset = positionToOffset(this.text, position);
|
|
401
|
+
return this.state.bindings
|
|
402
|
+
.filter((b) => b.span.start <= offset && offset < b.span.end)
|
|
403
|
+
.sort((a, b) => a.span.end - a.span.start - (b.span.end - b.span.start))[0]?.key;
|
|
404
|
+
}
|
|
405
|
+
definition(key) {
|
|
406
|
+
if (!this.state.valid || this.state.pending)
|
|
407
|
+
return;
|
|
408
|
+
const b = this.state.bindings.find((b) => b.key === key && b.kind === "definition");
|
|
409
|
+
return b ? this.range(b.span) : undefined;
|
|
410
|
+
}
|
|
411
|
+
highlights(position) {
|
|
412
|
+
const key = this.locate(position);
|
|
413
|
+
return key
|
|
414
|
+
? this.state.bindings
|
|
415
|
+
.filter((b) => b.key === key)
|
|
416
|
+
.map((b) => this.range(b.span))
|
|
417
|
+
: [];
|
|
418
|
+
}
|
|
419
|
+
prepareContentEdit(key, patch) {
|
|
420
|
+
if (!this.state.valid || this.state.pending)
|
|
421
|
+
throw new ContentEditError("stale-analysis", "Current valid analysis is required.");
|
|
422
|
+
const record = this.records.find((r) => r.thought.key === key);
|
|
423
|
+
if (!record)
|
|
424
|
+
throw new ContentEditError("unknown-key", "Unknown thought key.");
|
|
425
|
+
const ast = record.ast;
|
|
426
|
+
const changes = [];
|
|
427
|
+
const unsupported = () => new ContentEditError("unsupported", "This source form cannot be edited safely.");
|
|
428
|
+
if (isAlias(ast))
|
|
429
|
+
throw unsupported();
|
|
430
|
+
const scalarText = (start, end, value) => {
|
|
431
|
+
const original = this.text.slice(start, end);
|
|
432
|
+
const comment = /^[|>][0-9+-]*[ \t]*(#[^\r\n]*)/.exec(original)?.[1];
|
|
433
|
+
return (value +
|
|
434
|
+
(comment ? ` ${comment}` : "") +
|
|
435
|
+
(original.endsWith("\r\n")
|
|
436
|
+
? "\r\n"
|
|
437
|
+
: original.endsWith("\n")
|
|
438
|
+
? "\n"
|
|
439
|
+
: ""));
|
|
440
|
+
};
|
|
441
|
+
const fields = ["summary", "full_text"].filter((field) => patch[field] !== undefined);
|
|
442
|
+
if (isScalar(ast)) {
|
|
443
|
+
if (fields.includes("summary"))
|
|
444
|
+
throw unsupported();
|
|
445
|
+
if (fields.includes("full_text")) {
|
|
446
|
+
if (patch.full_text === null)
|
|
447
|
+
throw unsupported();
|
|
448
|
+
changes.push({
|
|
449
|
+
...record.span,
|
|
450
|
+
newText: scalarText(record.span.start, record.span.end, JSON.stringify(patch.full_text)),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
else if (isMap(ast)) {
|
|
455
|
+
for (const field of fields) {
|
|
456
|
+
const value = ast.get(field, true);
|
|
457
|
+
const newText = JSON.stringify(patch[field]);
|
|
458
|
+
if (value && isScalar(value) && value.range) {
|
|
459
|
+
changes.push({
|
|
460
|
+
start: value.range[0],
|
|
461
|
+
end: value.range[1],
|
|
462
|
+
newText: scalarText(value.range[0], value.range[1], newText),
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
else if (value !== undefined)
|
|
466
|
+
throw unsupported();
|
|
467
|
+
else {
|
|
468
|
+
if (ast.flow)
|
|
469
|
+
throw unsupported();
|
|
470
|
+
const first = ast.items[0]?.key;
|
|
471
|
+
if (!isScalar(first) || !first.range)
|
|
472
|
+
throw unsupported();
|
|
473
|
+
const start = first.range[0];
|
|
474
|
+
const lineStart = this.text.lastIndexOf("\n", start - 1) + 1;
|
|
475
|
+
// Insert before the first key, retaining the existing sequence marker.
|
|
476
|
+
const indent = " ".repeat(start - lineStart);
|
|
477
|
+
changes.push({
|
|
478
|
+
start,
|
|
479
|
+
end: start,
|
|
480
|
+
newText: `${field}: ${newText}\n${indent}`,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
else if (fields.length)
|
|
486
|
+
throw unsupported();
|
|
487
|
+
let source = this.text;
|
|
488
|
+
for (const change of [...changes].sort((a, b) => b.start - a.start))
|
|
489
|
+
source =
|
|
490
|
+
source.slice(0, change.start) +
|
|
491
|
+
change.newText +
|
|
492
|
+
source.slice(change.end);
|
|
493
|
+
const candidate = new DocumentSession(this.uri, source, this.version);
|
|
494
|
+
if (!candidate.snapshot().valid)
|
|
495
|
+
throw new ContentEditError("invalid-edit", "Proposed content does not form a valid document.");
|
|
496
|
+
return {
|
|
497
|
+
source,
|
|
498
|
+
edits: changes.map((c) => ({ range: this.range(c), newText: c.newText })),
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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 ConnectionTypeSchema: z.ZodEnum<{
|
|
8
|
+
connected: "connected";
|
|
9
|
+
external_link: "external_link";
|
|
10
|
+
follows: "follows";
|
|
11
|
+
map_link: "map_link";
|
|
12
|
+
optional: "optional";
|
|
13
|
+
precedes: "precedes";
|
|
14
|
+
prevents: "prevents";
|
|
15
|
+
}>;
|
|
16
|
+
export type ConnectionType = z.infer<typeof ConnectionTypeSchema>;
|
|
17
|
+
export declare const ReferenceSchema: z.ZodObject<{
|
|
18
|
+
node_id: z.ZodString;
|
|
19
|
+
type: z.ZodOptional<z.ZodEnum<{
|
|
20
|
+
connected: "connected";
|
|
21
|
+
external_link: "external_link";
|
|
22
|
+
follows: "follows";
|
|
23
|
+
map_link: "map_link";
|
|
24
|
+
optional: "optional";
|
|
25
|
+
precedes: "precedes";
|
|
26
|
+
prevents: "prevents";
|
|
27
|
+
}>>;
|
|
28
|
+
}, z.core.$strict>;
|
|
29
|
+
declare const NodeContentSchema: z.ZodObject<{
|
|
30
|
+
id: z.ZodOptional<z.ZodString>;
|
|
31
|
+
summary: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
32
|
+
full_text: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
33
|
+
position: z.ZodOptional<z.ZodObject<{
|
|
34
|
+
x: z.ZodNumber;
|
|
35
|
+
y: z.ZodNumber;
|
|
36
|
+
}, z.core.$strict>>;
|
|
37
|
+
}, z.core.$strict>;
|
|
38
|
+
export type Reference = z.infer<typeof ReferenceSchema>;
|
|
39
|
+
export type RichNode = z.infer<typeof NodeContentSchema> & {
|
|
40
|
+
connections?: Connection[];
|
|
41
|
+
type?: ConnectionType;
|
|
42
|
+
url?: string;
|
|
43
|
+
};
|
|
44
|
+
export type Connection = string | RichNode | Reference | Connection[];
|
|
45
|
+
export declare const RichNodeSchema: z.ZodType<RichNode>;
|
|
46
|
+
export declare const ConnectionSchema: z.ZodType<Connection>;
|
|
47
|
+
export declare const NodeSchema: z.ZodUnion<readonly [z.ZodString, z.ZodType<RichNode, unknown, z.core.$ZodTypeInternals<RichNode, unknown>>]>;
|
|
48
|
+
export declare const DocumentSchema: z.ZodObject<{
|
|
49
|
+
version: z.ZodLiteral<"v1">;
|
|
50
|
+
nodes: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodType<RichNode, unknown, z.core.$ZodTypeInternals<RichNode, unknown>>]>>;
|
|
51
|
+
}, z.core.$strict>;
|
|
52
|
+
export type LensmapDocument = z.infer<typeof DocumentSchema>;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
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 ConnectionTypeSchema = z.enum([
|
|
9
|
+
"connected",
|
|
10
|
+
"precedes",
|
|
11
|
+
"follows",
|
|
12
|
+
"prevents",
|
|
13
|
+
"optional",
|
|
14
|
+
"external_link",
|
|
15
|
+
"map_link",
|
|
16
|
+
]);
|
|
17
|
+
const LocalTypeSchema = z.enum([
|
|
18
|
+
"connected",
|
|
19
|
+
"precedes",
|
|
20
|
+
"follows",
|
|
21
|
+
"prevents",
|
|
22
|
+
"optional",
|
|
23
|
+
]);
|
|
24
|
+
export const ReferenceSchema = z.strictObject({
|
|
25
|
+
node_id: IdSchema,
|
|
26
|
+
type: ConnectionTypeSchema.optional(),
|
|
27
|
+
});
|
|
28
|
+
const NodeContentSchema = z.strictObject({
|
|
29
|
+
id: IdSchema.optional(),
|
|
30
|
+
summary: z.string().nullable().optional(),
|
|
31
|
+
full_text: z.string().nullable().optional(),
|
|
32
|
+
position: PositionSchema.optional(),
|
|
33
|
+
});
|
|
34
|
+
const ordinary = (typed) => {
|
|
35
|
+
const base = typed
|
|
36
|
+
? NodeContentSchema.extend({ type: LocalTypeSchema.optional() })
|
|
37
|
+
: NodeContentSchema;
|
|
38
|
+
return z.union([
|
|
39
|
+
base.extend({
|
|
40
|
+
summary: z.string().nullable(),
|
|
41
|
+
connections: z.array(ConnectionSchema).optional(),
|
|
42
|
+
}),
|
|
43
|
+
base.extend({
|
|
44
|
+
full_text: z.string().nullable(),
|
|
45
|
+
connections: z.array(ConnectionSchema).optional(),
|
|
46
|
+
}),
|
|
47
|
+
]);
|
|
48
|
+
};
|
|
49
|
+
const portal = () => NodeContentSchema.extend({
|
|
50
|
+
type: z.enum(["map_link", "external_link"]),
|
|
51
|
+
url: z.string().regex(/\S/, "A portal URL must not be blank."),
|
|
52
|
+
connections: z.array(ConnectionSchema).optional(),
|
|
53
|
+
});
|
|
54
|
+
export const RichNodeSchema = z.lazy(() => z.union([ordinary(true), portal()]));
|
|
55
|
+
const RootNodeSchema = z.lazy(() => z.union([ordinary(false), portal()]));
|
|
56
|
+
export const ConnectionSchema = z.lazy(() => z.union([
|
|
57
|
+
z.string(),
|
|
58
|
+
ReferenceSchema,
|
|
59
|
+
RichNodeSchema,
|
|
60
|
+
z.array(ConnectionSchema),
|
|
61
|
+
]));
|
|
62
|
+
export const NodeSchema = z.union([z.string(), RootNodeSchema]);
|
|
63
|
+
export const DocumentSchema = z.strictObject({
|
|
64
|
+
version: z.literal("v1"),
|
|
65
|
+
nodes: z.array(NodeSchema),
|
|
66
|
+
});
|
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.1",
|
|
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
|
+
"ajv": "8.20.0",
|
|
40
|
+
"typescript": "7.0.2",
|
|
41
|
+
"vitest": "5.1.0"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "vitest run"
|
|
47
|
+
}
|
|
6
48
|
}
|