@lensmap/document 0.1.0 → 0.2.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/README.md CHANGED
@@ -5,10 +5,10 @@ semantic graphs, revision-bound source bindings, session identities and bounded
5
5
  source-preserving content edits. Does not own layout, browser rendering or LSP.
6
6
 
7
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.
8
+ are incomplete and experimental; `0.x` is not a promise of a stable schema.
9
9
 
10
10
  ```sh
11
- npm install @lensmap/document@next
11
+ npm install @lensmap/document
12
12
  ```
13
13
 
14
14
  ```js
package/dist/index.d.ts CHANGED
@@ -1,10 +1,22 @@
1
1
  import { z } from "zod";
2
- export type { LensmapDocument, Reference, RichNode } from "./v1/schema.js";
3
- export { DocumentSchema } from "./v1/schema.js";
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";
4
5
  export declare function generateJSONSchema(): z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
5
6
  version: z.ZodLiteral<"v1">;
7
+ frontmatter: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
6
8
  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
9
  }, z.core.$strict>>;
10
+ /** Read the edge from the inspected endpoint, independently of structural kind. */
11
+ export declare function connectionWording(type: ConnectionType | undefined, direction: "outgoing" | "incoming"): string;
12
+ type AnchorThought = {
13
+ readonly key: string;
14
+ readonly id?: string;
15
+ };
16
+ /** Generated spelling is private to this rendered document version. */
17
+ export declare function thoughtAnchors(nodes: readonly AnchorThought[]): Map<string, string>;
18
+ /** The host decodes the fragment once before calling; authored names stay exact. */
19
+ export declare function resolveThoughtAnchor(nodes: readonly AnchorThought[], decodedAnchor: string): string | undefined;
8
20
  export type SourcePosition = {
9
21
  line: number;
10
22
  character: number;
@@ -28,12 +40,17 @@ export type Thought = {
28
40
  y: number;
29
41
  };
30
42
  path: string;
43
+ destination?: {
44
+ kind: "map_link" | "external_link";
45
+ url: string;
46
+ };
31
47
  };
32
48
  export type Connection = {
33
49
  key: string;
34
50
  from: string;
35
51
  to: string;
36
52
  kind: "branch" | "cross";
53
+ type?: ConnectionType;
37
54
  path: string;
38
55
  };
39
56
  export type SemanticGraph = {
@@ -51,6 +68,7 @@ export type Diagnostic = {
51
68
  range: SourceRange;
52
69
  };
53
70
  export type DocumentSnapshot = {
71
+ attachmentId?: string;
54
72
  uri: string;
55
73
  revision: number;
56
74
  analyzedRevision: number | null;
@@ -80,6 +98,7 @@ export declare function positionToOffset(source: string, position: SourcePositio
80
98
  export declare function offsetToPosition(source: string, offset: number): SourcePosition;
81
99
  export declare class DocumentSession {
82
100
  readonly uri: string;
101
+ private readonly attachmentId;
83
102
  private text;
84
103
  private version;
85
104
  private state;
package/dist/index.js CHANGED
@@ -1,10 +1,58 @@
1
1
  import { isAlias, isMap, isScalar, parseDocument } from "yaml";
2
2
  import { z } from "zod";
3
3
  import { DocumentSchema, } from "./v1/schema.js";
4
- export { DocumentSchema } from "./v1/schema.js";
4
+ export { ConnectionTypeSchema, DocumentSchema } from "./v1/schema.js";
5
5
  export function generateJSONSchema() {
6
6
  return z.toJSONSchema(DocumentSchema, { io: "input" });
7
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;
8
56
  export class ContentEditError extends Error {
9
57
  code;
10
58
  constructor(code, message) {
@@ -45,6 +93,7 @@ function rejectCycles(value, ancestors = new Set()) {
45
93
  }
46
94
  export class DocumentSession {
47
95
  uri;
96
+ attachmentId = `attachment:${++attachmentSerial}`;
48
97
  text;
49
98
  version;
50
99
  state;
@@ -57,6 +106,7 @@ export class DocumentSession {
57
106
  this.version = revision;
58
107
  this.state = {
59
108
  uri,
109
+ attachmentId: this.attachmentId,
60
110
  revision,
61
111
  analyzedRevision: null,
62
112
  graphRevision: null,
@@ -111,8 +161,27 @@ export class DocumentSession {
111
161
  const input = doc.toJS();
112
162
  rejectCycles(input);
113
163
  const result = DocumentSchema.safeParse(input);
114
- if (!result.success)
115
- throw new Error(z.prettifyError(result.error));
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
+ }
116
185
  const records = [];
117
186
  const edges = [];
118
187
  const bindings = [];
@@ -157,6 +226,7 @@ export class DocumentSession {
157
226
  refs.push({
158
227
  from: parent,
159
228
  id: entry.node_id,
229
+ type: entry.type,
160
230
  path: pathName,
161
231
  span,
162
232
  });
@@ -167,10 +237,17 @@ export class DocumentSession {
167
237
  : Array.isArray(entry)
168
238
  ? { full_text: null, connections: entry }
169
239
  : entry;
170
- const markdown = content.full_text ?? content.summary ?? "";
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;
171
248
  const title = content.summary !== undefined
172
249
  ? (content.summary ?? "")
173
- : (content.full_text ?? "").split(/\r?\n/, 1)[0];
250
+ : (content.full_text ?? fallback).split(/\r?\n/, 1)[0];
174
251
  const key = `thought:${++this.serial}`;
175
252
  const thought = {
176
253
  key,
@@ -180,6 +257,7 @@ export class DocumentSession {
180
257
  unresolved: title.trim() === "",
181
258
  position: content.position,
182
259
  path: pathName,
260
+ ...(destination ? { destination } : {}),
183
261
  };
184
262
  records.push({ thought, span, ast });
185
263
  bindings.push({ key, kind: "definition", span });
@@ -194,6 +272,7 @@ export class DocumentSession {
194
272
  from: parent,
195
273
  to: key,
196
274
  kind: "branch",
275
+ ...(content.type !== undefined ? { type: content.type } : {}),
197
276
  path: pathName,
198
277
  });
199
278
  content.connections?.forEach((child, i) => {
@@ -257,7 +336,11 @@ export class DocumentSession {
257
336
  for (const ref of refs) {
258
337
  const to = ids.get(ref.id);
259
338
  if (!to)
260
- throw new Error(`${ref.path}.node_id: no node has id "${ref.id}".`);
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 });
261
344
  const endpoints = `${ref.from}:${to}`;
262
345
  const occurrence = parallelReferences.get(endpoints) ?? 0;
263
346
  parallelReferences.set(endpoints, occurrence + 1);
@@ -266,6 +349,7 @@ export class DocumentSession {
266
349
  from: ref.from,
267
350
  to,
268
351
  kind: "cross",
352
+ ...(ref.type !== undefined ? { type: ref.type } : {}),
269
353
  path: ref.path,
270
354
  });
271
355
  bindings.push({
@@ -279,6 +363,7 @@ export class DocumentSession {
279
363
  this.validSource = this.text;
280
364
  this.state = {
281
365
  uri: this.uri,
366
+ attachmentId: this.attachmentId,
282
367
  revision: this.version,
283
368
  analyzedRevision: this.version,
284
369
  graphRevision: this.version,
@@ -4,8 +4,27 @@ export declare const PositionSchema: z.ZodObject<{
4
4
  x: z.ZodNumber;
5
5
  y: z.ZodNumber;
6
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>;
7
17
  export declare const ReferenceSchema: z.ZodObject<{
8
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
+ }>>;
9
28
  }, z.core.$strict>;
10
29
  declare const NodeContentSchema: z.ZodObject<{
11
30
  id: z.ZodOptional<z.ZodString>;
@@ -19,6 +38,8 @@ declare const NodeContentSchema: z.ZodObject<{
19
38
  export type Reference = z.infer<typeof ReferenceSchema>;
20
39
  export type RichNode = z.infer<typeof NodeContentSchema> & {
21
40
  connections?: Connection[];
41
+ type?: ConnectionType;
42
+ url?: string;
22
43
  };
23
44
  export type Connection = string | RichNode | Reference | Connection[];
24
45
  export declare const RichNodeSchema: z.ZodType<RichNode>;
@@ -26,6 +47,7 @@ export declare const ConnectionSchema: z.ZodType<Connection>;
26
47
  export declare const NodeSchema: z.ZodUnion<readonly [z.ZodString, z.ZodType<RichNode, unknown, z.core.$ZodTypeInternals<RichNode, unknown>>]>;
27
48
  export declare const DocumentSchema: z.ZodObject<{
28
49
  version: z.ZodLiteral<"v1">;
50
+ frontmatter: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
29
51
  nodes: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodType<RichNode, unknown, z.core.$ZodTypeInternals<RichNode, unknown>>]>>;
30
52
  }, z.core.$strict>;
31
53
  export type LensmapDocument = z.infer<typeof DocumentSchema>;
package/dist/v1/schema.js CHANGED
@@ -5,31 +5,63 @@ export const PositionSchema = z.strictObject({
5
5
  y: z.number().finite(),
6
6
  });
7
7
  const IdSchema = z.string().regex(/\S/, "An ID must not be empty.");
8
- export const ReferenceSchema = z.strictObject({ node_id: IdSchema });
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
+ });
9
28
  const NodeContentSchema = z.strictObject({
10
29
  id: IdSchema.optional(),
11
30
  summary: z.string().nullable().optional(),
12
31
  full_text: z.string().nullable().optional(),
13
32
  position: PositionSchema.optional(),
14
33
  });
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
- ]));
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()]));
25
56
  export const ConnectionSchema = z.lazy(() => z.union([
26
57
  z.string(),
27
58
  ReferenceSchema,
28
59
  RichNodeSchema,
29
60
  z.array(ConnectionSchema),
30
61
  ]));
31
- export const NodeSchema = z.union([z.string(), RichNodeSchema]);
62
+ export const NodeSchema = z.union([z.string(), RootNodeSchema]);
32
63
  export const DocumentSchema = z.strictObject({
33
64
  version: z.literal("v1"),
65
+ frontmatter: z.record(z.string(), z.unknown()).optional(),
34
66
  nodes: z.array(NodeSchema),
35
67
  });
package/package.json CHANGED
@@ -2,13 +2,13 @@
2
2
  "name": "@lensmap/document",
3
3
  "license": "MIT",
4
4
  "description": "Experimental Lensmap document engine: YAML analysis, semantic graphs and revision-bound source edits",
5
- "version": "0.1.0",
5
+ "version": "0.2.0",
6
6
  "files": [
7
7
  "dist"
8
8
  ],
9
9
  "publishConfig": {
10
10
  "access": "public",
11
- "tag": "next",
11
+ "tag": "latest",
12
12
  "registry": "https://registry.npmjs.org/"
13
13
  },
14
14
  "repository": {
@@ -36,9 +36,9 @@
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "24.19.2",
39
+ "ajv": "8.20.0",
39
40
  "typescript": "7.0.2",
40
- "vitest": "5.1.0",
41
- "ajv": "8.20.0"
41
+ "vitest": "5.1.0"
42
42
  },
43
43
  "scripts": {
44
44
  "build": "tsc",