@lensmap/document 0.2.0 → 0.3.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.
@@ -1,9 +1,14 @@
1
1
  import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname, resolve } from "node:path";
3
- import { generateJSONSchema } from "./index.js";
3
+ import { generateJSONSchema, generateVocabularyJSONSchema } from "./index.js";
4
4
  const target = process.argv[2];
5
5
  if (!target)
6
6
  throw new Error("Supply the generated schema's output filename.");
7
7
  const path = resolve(target);
8
8
  await mkdir(dirname(path), { recursive: true });
9
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`);
10
+ if (process.argv[3]) {
11
+ const vocabulary = resolve(process.argv[3]);
12
+ await mkdir(dirname(vocabulary), { recursive: true });
13
+ await writeFile(vocabulary, `${JSON.stringify({ ...generateVocabularyJSONSchema(), title: "Lensmap experimental connection vocabulary", $comment: "Generated from the owning Zod input schema. Layered inheritance and CSS value semantics are checked by the engine." }, null, 2)}\n`);
14
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import { type NodePresentation } from "./node-style.js";
2
+ export type { NodePresentation, NodeStyleDeclaration } from "./node-style.js";
3
+ import { type ConnectionPresentation, type ResolvedConnectionType, type VocabularyInput } from "./vocabulary.js";
4
+ export * from "./vocabulary.js";
1
5
  import { z } from "zod";
2
6
  import { type ConnectionType } from "./v1/schema.js";
3
7
  export type { ConnectionType, LensmapDocument, Reference, RichNode, } from "./v1/schema.js";
@@ -5,10 +9,15 @@ export { ConnectionTypeSchema, DocumentSchema } from "./v1/schema.js";
5
9
  export declare function generateJSONSchema(): z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
6
10
  version: z.ZodLiteral<"v1">;
7
11
  frontmatter: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
12
+ connection_types: z.ZodOptional<z.ZodType<import("./vocabulary.js").ConnectionTypeDeclarations, unknown, z.core.$ZodTypeInternals<import("./vocabulary.js").ConnectionTypeDeclarations, unknown>>>;
13
+ node_styles: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>]>>>;
8
14
  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>>]>>;
9
15
  }, z.core.$strict>>;
16
+ export declare function generateVocabularyJSONSchema(): z.core.ZodStandardJSONSchemaPayload<z.ZodObject<{
17
+ connection_types: z.ZodType<import("./vocabulary.js").ConnectionTypeDeclarations, unknown, z.core.$ZodTypeInternals<import("./vocabulary.js").ConnectionTypeDeclarations, unknown>>;
18
+ }, z.core.$strict>>;
10
19
  /** Read the edge from the inspected endpoint, independently of structural kind. */
11
- export declare function connectionWording(type: ConnectionType | undefined, direction: "outgoing" | "incoming"): string;
20
+ export declare function connectionWording(type: ConnectionType | undefined, direction: "outgoing" | "incoming", presentation?: ConnectionPresentation): string;
12
21
  type AnchorThought = {
13
22
  readonly key: string;
14
23
  readonly id?: string;
@@ -39,6 +48,7 @@ export type Thought = {
39
48
  x: number;
40
49
  y: number;
41
50
  };
51
+ style?: NodePresentation;
42
52
  path: string;
43
53
  destination?: {
44
54
  kind: "map_link" | "external_link";
@@ -51,6 +61,7 @@ export type Connection = {
51
61
  to: string;
52
62
  kind: "branch" | "cross";
53
63
  type?: ConnectionType;
64
+ presentation?: ConnectionPresentation;
54
65
  path: string;
55
66
  };
56
67
  export type SemanticGraph = {
@@ -66,8 +77,11 @@ export type Binding = {
66
77
  export type Diagnostic = {
67
78
  message: string;
68
79
  range: SourceRange;
80
+ uri?: string;
69
81
  };
70
82
  export type DocumentSnapshot = {
83
+ vocabulary?: Record<string, ResolvedConnectionType>;
84
+ graphGeneration?: number;
71
85
  attachmentId?: string;
72
86
  uri: string;
73
87
  revision: number;
@@ -98,6 +112,7 @@ export declare function positionToOffset(source: string, position: SourcePositio
98
112
  export declare function offsetToPosition(source: string, offset: number): SourcePosition;
99
113
  export declare class DocumentSession {
100
114
  readonly uri: string;
115
+ private vocabularyInput;
101
116
  private readonly attachmentId;
102
117
  private text;
103
118
  private version;
@@ -105,7 +120,8 @@ export declare class DocumentSession {
105
120
  private records;
106
121
  private validSource;
107
122
  private serial;
108
- constructor(uri: string, source?: string, revision?: number);
123
+ constructor(uri: string, source?: string, revision?: number, vocabularyInput?: VocabularyInput);
124
+ setVocabulary(input: VocabularyInput): DocumentSnapshot;
109
125
  get source(): string;
110
126
  get revision(): number;
111
127
  receive(source: string, revision: number): DocumentSnapshot;
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { parseNodeStyle, resolveNodeStyle, } from "./node-style.js";
2
+ import { resolveVocabulary, VocabularySchema, } from "./vocabulary.js";
3
+ export * from "./vocabulary.js";
1
4
  import { isAlias, isMap, isScalar, parseDocument } from "yaml";
2
5
  import { z } from "zod";
3
6
  import { DocumentSchema, } from "./v1/schema.js";
@@ -5,8 +8,15 @@ export { ConnectionTypeSchema, DocumentSchema } from "./v1/schema.js";
5
8
  export function generateJSONSchema() {
6
9
  return z.toJSONSchema(DocumentSchema, { io: "input" });
7
10
  }
11
+ export function generateVocabularyJSONSchema() {
12
+ return z.toJSONSchema(VocabularySchema, { io: "input" });
13
+ }
8
14
  /** Read the edge from the inspected endpoint, independently of structural kind. */
9
- export function connectionWording(type, direction) {
15
+ export function connectionWording(type, direction, presentation) {
16
+ if (presentation)
17
+ return direction === "outgoing"
18
+ ? presentation.text
19
+ : presentation.reverseText;
10
20
  switch (type) {
11
21
  case "precedes":
12
22
  return direction === "outgoing" ? "precedes" : "follows";
@@ -93,6 +103,7 @@ function rejectCycles(value, ancestors = new Set()) {
93
103
  }
94
104
  export class DocumentSession {
95
105
  uri;
106
+ vocabularyInput;
96
107
  attachmentId = `attachment:${++attachmentSerial}`;
97
108
  text;
98
109
  version;
@@ -100,8 +111,9 @@ export class DocumentSession {
100
111
  records = [];
101
112
  validSource = "";
102
113
  serial = 0;
103
- constructor(uri, source = "", revision = 0) {
114
+ constructor(uri, source = "", revision = 0, vocabularyInput = {}) {
104
115
  this.uri = uri;
116
+ this.vocabularyInput = vocabularyInput;
105
117
  this.text = source;
106
118
  this.version = revision;
107
119
  this.state = {
@@ -118,6 +130,10 @@ export class DocumentSession {
118
130
  };
119
131
  this.analyze();
120
132
  }
133
+ setVocabulary(input) {
134
+ this.vocabularyInput = input;
135
+ return this.analyze();
136
+ }
121
137
  get source() {
122
138
  return this.text;
123
139
  }
@@ -182,6 +198,36 @@ export class DocumentSession {
182
198
  .sort((a, b) => b.path.length - a.path.length)[0];
183
199
  throw Object.assign(new Error(located?.issue.message ?? z.prettifyError(result.error)), { offset: located?.offset ?? 0 });
184
200
  }
201
+ const vocabulary = resolveVocabulary(this.vocabularyInput, result.data.connection_types, { source: this.text, uri: this.uri });
202
+ const definition = (type, offset = 0) => {
203
+ if (!Object.hasOwn(vocabulary, type))
204
+ throw Object.assign(new Error(`Unknown connection type: ${type}. Available types: ${Object.keys(vocabulary).join(", ")}`), { offset });
205
+ return vocabulary[type];
206
+ };
207
+ const styleRules = new Map();
208
+ const ruleSyntax = doc.get("node_styles", true);
209
+ const resolvedRules = isAlias(ruleSyntax)
210
+ ? ruleSyntax.resolve(doc)
211
+ : ruleSyntax;
212
+ // JS objects reorder integer-looking keys. CSS rule order is the author's
213
+ // YAML order, not Object.entries order after structural validation.
214
+ const ruleNames = isMap(resolvedRules)
215
+ ? resolvedRules.items.map((pair) => String(isScalar(pair.key) ? pair.key.value : ""))
216
+ : Object.keys(result.data.node_styles ?? {});
217
+ for (const name of ruleNames) {
218
+ const style = result.data.node_styles?.[name];
219
+ if (style === undefined)
220
+ continue;
221
+ try {
222
+ styleRules.set(name, parseNodeStyle(style));
223
+ }
224
+ catch (error) {
225
+ throw Object.assign(error instanceof Error ? error : new Error(String(error)), {
226
+ offset: doc.getIn(["node_styles", name], true)
227
+ ?.range?.[0] ?? 0,
228
+ });
229
+ }
230
+ }
185
231
  const records = [];
186
232
  const edges = [];
187
233
  const bindings = [];
@@ -237,9 +283,19 @@ export class DocumentSession {
237
283
  : Array.isArray(entry)
238
284
  ? { full_text: null, connections: entry }
239
285
  : entry;
240
- const destination = (content.type === "map_link" || content.type === "external_link") &&
286
+ const coreKind = content.type === undefined
287
+ ? "ordinary"
288
+ : definition(content.type, span.start).coreKind;
289
+ if (!parent && content.type !== undefined && coreKind === "ordinary")
290
+ throw Object.assign(new Error("Ordinary relationship types are not starting thoughts"), { offset: span.start });
291
+ if ((coreKind === "ordinary" && content.url !== undefined) ||
292
+ (coreKind !== "ordinary" && content.url === undefined))
293
+ throw Object.assign(new Error("Invalid portal definition"), {
294
+ offset: span.start,
295
+ });
296
+ const destination = (coreKind === "map_link" || coreKind === "external_link") &&
241
297
  content.url !== undefined
242
- ? { kind: content.type, url: content.url }
298
+ ? { kind: coreKind, url: content.url }
243
299
  : undefined;
244
300
  const fallback = content.summary === undefined && content.full_text === undefined
245
301
  ? (destination?.url ?? "")
@@ -249,6 +305,20 @@ export class DocumentSession {
249
305
  ? (content.summary ?? "")
250
306
  : (content.full_text ?? fallback).split(/\r?\n/, 1)[0];
251
307
  const key = `thought:${++this.serial}`;
308
+ let style;
309
+ try {
310
+ style = resolveNodeStyle(content.classes, content.style, styleRules);
311
+ }
312
+ catch (error) {
313
+ throw Object.assign(error instanceof Error ? error : new Error(String(error)), {
314
+ offset: doc.getIn([
315
+ ...path,
316
+ content.classes?.some((name) => !styleRules.has(name))
317
+ ? "classes"
318
+ : "style",
319
+ ], true)?.range?.[0] ?? span.start,
320
+ });
321
+ }
252
322
  const thought = {
253
323
  key,
254
324
  id: content.id,
@@ -258,6 +328,7 @@ export class DocumentSession {
258
328
  position: content.position,
259
329
  path: pathName,
260
330
  ...(destination ? { destination } : {}),
331
+ ...(style ? { style } : {}),
261
332
  };
262
333
  records.push({ thought, span, ast });
263
334
  bindings.push({ key, kind: "definition", span });
@@ -337,9 +408,10 @@ export class DocumentSession {
337
408
  const to = ids.get(ref.id);
338
409
  if (!to)
339
410
  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") &&
411
+ if (ref.type !== undefined &&
412
+ definition(ref.type, ref.span.start).coreKind !== "ordinary" &&
341
413
  records.find((r) => r.thought.key === to)?.thought.destination
342
- ?.kind !== ref.type)
414
+ ?.kind !== definition(ref.type, ref.span.start).coreKind)
343
415
  throw Object.assign(new Error(`${ref.path}.type: reference requires a ${ref.type} destination.`), { offset: ref.span.start });
344
416
  const endpoints = `${ref.from}:${to}`;
345
417
  const occurrence = parallelReferences.get(endpoints) ?? 0;
@@ -359,6 +431,11 @@ export class DocumentSession {
359
431
  ownerKey: ref.from,
360
432
  });
361
433
  }
434
+ for (const edge of edges)
435
+ if (edge.type !== undefined) {
436
+ const { coreKind: _coreKind, ...presentation } = definition(edge.type);
437
+ edge.presentation = presentation;
438
+ }
362
439
  this.records = records;
363
440
  this.validSource = this.text;
364
441
  this.state = {
@@ -367,6 +444,8 @@ export class DocumentSession {
367
444
  revision: this.version,
368
445
  analyzedRevision: this.version,
369
446
  graphRevision: this.version,
447
+ graphGeneration: (this.state.graphGeneration ?? 0) + 1,
448
+ vocabulary,
370
449
  valid: true,
371
450
  pending: false,
372
451
  graph: { nodes: records.map((r) => r.thought), edges },
@@ -378,6 +457,8 @@ export class DocumentSession {
378
457
  const offset = typeof error === "object" && error !== null && "offset" in error
379
458
  ? Number(error.offset)
380
459
  : 0;
460
+ const context = error;
461
+ const diagnosticSource = context?.source ?? this.text;
381
462
  this.state = {
382
463
  ...this.state,
383
464
  analyzedRevision: this.version,
@@ -387,7 +468,11 @@ export class DocumentSession {
387
468
  diagnostics: [
388
469
  {
389
470
  message: error instanceof Error ? error.message : String(error),
390
- range: this.range({ start: offset, end: offset }),
471
+ range: {
472
+ start: offsetToPosition(diagnosticSource, offset),
473
+ end: offsetToPosition(diagnosticSource, offset),
474
+ },
475
+ ...(context?.uri ? { uri: context.uri } : {}),
391
476
  },
392
477
  ],
393
478
  };
@@ -490,7 +575,7 @@ export class DocumentSession {
490
575
  source.slice(0, change.start) +
491
576
  change.newText +
492
577
  source.slice(change.end);
493
- const candidate = new DocumentSession(this.uri, source, this.version);
578
+ const candidate = new DocumentSession(this.uri, source, this.version, this.vocabularyInput);
494
579
  if (!candidate.snapshot().valid)
495
580
  throw new ContentEditError("invalid-edit", "Proposed content does not form a valid document.");
496
581
  return {
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ export type NodeStyleDeclaration = {
3
+ property: string;
4
+ value: string;
5
+ important: boolean;
6
+ };
7
+ export type NodePresentation = {
8
+ declarations: NodeStyleDeclaration[];
9
+ scale?: number | "auto";
10
+ icon?: string;
11
+ };
12
+ export declare const NodeStyleSchema: z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>]>;
13
+ export type NodeStyle = z.infer<typeof NodeStyleSchema>;
14
+ /** Parse inert declarations only; never return authored stylesheet text. */
15
+ export declare function parseNodeStyle(input: NodeStyle): NodeStyleDeclaration[];
16
+ /** Low-to-high cascade order preserves shorthand interactions for browser application. */
17
+ export declare function resolveNodeStyle(classes: string[] | undefined, inline: NodeStyle | undefined, rules: Map<string, NodeStyleDeclaration[]>): NodePresentation | undefined;
@@ -0,0 +1,165 @@
1
+ import * as css from "css-tree";
2
+ import { z } from "zod";
3
+ export const NodeStyleSchema = z.union([
4
+ z.string(),
5
+ z.record(z.string(), z.union([z.string(), z.number().finite()])),
6
+ ]);
7
+ const properties = new Set([
8
+ "color",
9
+ "background-color",
10
+ "font-family",
11
+ "font-size",
12
+ "font-weight",
13
+ "font-style",
14
+ "line-height",
15
+ "border",
16
+ "border-color",
17
+ "border-width",
18
+ "border-style",
19
+ "padding",
20
+ ...["top", "right", "bottom", "left"].flatMap((side) => [
21
+ `padding-${side}`,
22
+ `border-${side}`,
23
+ `border-${side}-color`,
24
+ `border-${side}-width`,
25
+ `border-${side}-style`,
26
+ ]),
27
+ ]);
28
+ const functions = new Set([
29
+ "rgb",
30
+ "rgba",
31
+ "hsl",
32
+ "hsla",
33
+ "hwb",
34
+ "lab",
35
+ "lch",
36
+ "oklab",
37
+ "oklch",
38
+ "color",
39
+ "color-mix",
40
+ "light-dark",
41
+ ]);
42
+ function fail(message) {
43
+ throw new Error(`Node style: ${message}`);
44
+ }
45
+ const unsafeText = (text) => [...text].some((char) => char === "<" || char === ">" || char.charCodeAt(0) < 32);
46
+ /** Parse inert declarations only; never return authored stylesheet text. */
47
+ export function parseNodeStyle(input) {
48
+ const parse = (text) => {
49
+ const ast = css.parse(text, {
50
+ context: "declarationList",
51
+ onParseError: (error) => {
52
+ throw error;
53
+ },
54
+ });
55
+ const out = [];
56
+ if (ast.type !== "DeclarationList")
57
+ return fail("Expected declarations");
58
+ ast.children.forEach((node) => {
59
+ if (node.type !== "Declaration")
60
+ fail("Only declarations are supported");
61
+ const decodedProperty = css.ident.decode(node.property);
62
+ const property = decodedProperty.startsWith("--")
63
+ ? decodedProperty
64
+ : decodedProperty.toLowerCase();
65
+ if (!properties.has(property) &&
66
+ property !== "--lensmap-scale" &&
67
+ property !== "--lensmap-icon")
68
+ fail(`Unsupported property ${property}`);
69
+ const valueText = css.generate(node.value).trim();
70
+ const value = css.parse(valueText, {
71
+ context: "value",
72
+ onParseError: (error) => {
73
+ throw error;
74
+ },
75
+ });
76
+ css.walk(value, (token) => {
77
+ if (token.type === "Raw" ||
78
+ token.type === "Url" ||
79
+ (token.type === "Function" &&
80
+ !functions.has(css.ident.decode(token.name).toLowerCase())))
81
+ fail(`Unsafe or unsupported value for ${property}`);
82
+ if (token.type === "String" && unsafeText(token.value))
83
+ fail("Unsafe text");
84
+ if (token.type === "Identifier" &&
85
+ /^(inherit|initial|unset|revert|revert-layer|infinity|-infinity|nan)$/i.test(css.ident.decode(token.name)))
86
+ fail("CSS-wide keywords are unsupported");
87
+ if ((token.type === "Number" ||
88
+ token.type === "Dimension" ||
89
+ token.type === "Percentage") &&
90
+ !Number.isFinite(Number(token.value)))
91
+ fail("Lengths and numbers must be finite");
92
+ });
93
+ if (property === "--lensmap-scale") {
94
+ const scalar = value.type === "Value" ? value.children.first : undefined;
95
+ if (valueText !== "auto" &&
96
+ (value.type !== "Value" ||
97
+ value.children.size !== 1 ||
98
+ scalar?.type !== "Number" ||
99
+ !Number.isFinite(Number(scalar.value)) ||
100
+ Number(scalar.value) <= 0))
101
+ fail("Scale must be positive or auto");
102
+ }
103
+ else if (property === "--lensmap-icon") {
104
+ if (value.type !== "Value" ||
105
+ value.children.size !== 1 ||
106
+ value.children.first?.type !== "String")
107
+ fail("Icon must be a CSS string");
108
+ const first = value.type === "Value" ? value.children.first : undefined;
109
+ if (first?.type === "String" && unsafeText(first.value))
110
+ fail("Unsafe icon text");
111
+ }
112
+ else if (css.lexer.matchProperty(property, value).error)
113
+ fail(`Invalid value for ${property}`);
114
+ out.push({
115
+ property,
116
+ value: valueText,
117
+ important: Boolean(node.important),
118
+ });
119
+ });
120
+ return out;
121
+ };
122
+ if (typeof input === "string")
123
+ return parse(input);
124
+ return Object.entries(input).flatMap(([property, value]) => {
125
+ if (!/^(?:--lensmap-(?:scale|icon)|[a-z-]+)$/i.test(property))
126
+ return fail(`Invalid property ${property}`);
127
+ const text = property === "--lensmap-icon"
128
+ ? css.generate({ type: "String", value: String(value) })
129
+ : String(value);
130
+ const declarations = parse(`${property}:${text}`);
131
+ if (declarations.length !== 1 ||
132
+ declarations[0].property !==
133
+ (property.startsWith("--") ? property : property.toLowerCase()))
134
+ fail("Mapping values must contain one declaration value");
135
+ return declarations;
136
+ });
137
+ }
138
+ /** Low-to-high cascade order preserves shorthand interactions for browser application. */
139
+ export function resolveNodeStyle(classes, inline, rules) {
140
+ for (const name of classes ?? [])
141
+ if (!rules.has(name))
142
+ fail(`Unknown class ${name}`);
143
+ const selected = new Set(classes);
144
+ const declarations = [...rules]
145
+ .filter(([name]) => selected.has(name))
146
+ .flatMap(([, values]) => values);
147
+ declarations.push(...(inline === undefined ? [] : parseNodeStyle(inline)));
148
+ declarations.sort((a, b) => Number(a.important) - Number(b.important));
149
+ if (!declarations.length)
150
+ return undefined;
151
+ const presentation = { declarations: [] };
152
+ for (const declaration of declarations) {
153
+ if (declaration.property === "--lensmap-scale")
154
+ presentation.scale =
155
+ declaration.value === "auto" ? "auto" : Number(declaration.value);
156
+ else if (declaration.property === "--lensmap-icon") {
157
+ const value = css.parse(declaration.value, { context: "value" });
158
+ if (value.type === "Value" && value.children.first?.type === "String")
159
+ presentation.icon = value.children.first.value;
160
+ }
161
+ else
162
+ presentation.declarations.push(declaration);
163
+ }
164
+ return presentation;
165
+ }
@@ -0,0 +1,6 @@
1
+ import { type VocabularyInput } from "./vocabulary.js";
2
+ /** Root-bounded Node adapter. The browser entry deliberately never imports it. */
3
+ export declare function readProjectVocabulary(root: string, buffers?: ReadonlyMap<string, string>): Promise<{
4
+ input: VocabularyInput;
5
+ files: string[];
6
+ }>;
@@ -0,0 +1,91 @@
1
+ import { lstat, readFile, realpath } from "node:fs/promises";
2
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { isScalar, parseDocument } from "yaml";
5
+ import { parseVocabulary } from "./vocabulary.js";
6
+ /** Root-bounded Node adapter. The browser entry deliberately never imports it. */
7
+ export async function readProjectVocabulary(root, buffers = new Map()) {
8
+ const config = resolve(root, "lensmap.config.json");
9
+ const files = [config];
10
+ let source;
11
+ let offset = 0;
12
+ try {
13
+ const canonicalRoot = await realpath(root);
14
+ const inside = (path) => {
15
+ const rel = relative(canonicalRoot, path);
16
+ return (rel === "" ||
17
+ (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)));
18
+ };
19
+ const authorize = async (path) => {
20
+ let ancestor = path;
21
+ for (;;) {
22
+ try {
23
+ await lstat(ancestor);
24
+ const canonical = await realpath(ancestor);
25
+ if (!inside(canonical))
26
+ throw new Error("Vocabulary path escapes project root");
27
+ return;
28
+ }
29
+ catch (error) {
30
+ if (error.code !== "ENOENT")
31
+ throw error;
32
+ const parent = dirname(ancestor);
33
+ if (parent === ancestor)
34
+ throw error;
35
+ ancestor = parent;
36
+ }
37
+ }
38
+ };
39
+ await authorize(config);
40
+ source = buffers.get(config);
41
+ if (source === undefined) {
42
+ try {
43
+ source = await readFile(config, "utf8");
44
+ }
45
+ catch (error) {
46
+ if (error.code === "ENOENT")
47
+ return { input: {}, files };
48
+ throw error;
49
+ }
50
+ }
51
+ // JSON.parse enforces JSON syntax; YAML's JSON mode additionally detects duplicate keys.
52
+ const json = JSON.parse(source);
53
+ const parsed = parseDocument(source, { schema: "json", uniqueKeys: true });
54
+ if (parsed.errors.length)
55
+ throw new Error(parsed.errors[0].message);
56
+ if (!json ||
57
+ typeof json !== "object" ||
58
+ Array.isArray(json) ||
59
+ Object.keys(json).length !== 1 ||
60
+ !Object.hasOwn(json, "types") ||
61
+ typeof json.types !== "string" ||
62
+ !json.types.trim())
63
+ throw new Error("Config requires exactly one nonblank types path");
64
+ const field = parsed.get("types", true);
65
+ if (isScalar(field))
66
+ offset = field.range?.[0] ?? 0;
67
+ const target = resolve(dirname(config), json.types);
68
+ const lexical = relative(resolve(root), target);
69
+ if (lexical === ".." ||
70
+ lexical.startsWith(`..${sep}`) ||
71
+ isAbsolute(lexical))
72
+ throw new Error("Vocabulary path escapes project root");
73
+ await authorize(target);
74
+ if (!target.endsWith(".lenstypes"))
75
+ throw new Error("Vocabulary must be a .lenstypes file");
76
+ files.push(target);
77
+ const text = buffers.get(target) ?? (await readFile(target, "utf8"));
78
+ return { input: parseVocabulary(text, pathToFileURL(target).href), files };
79
+ }
80
+ catch (error) {
81
+ return {
82
+ input: {
83
+ uri: pathToFileURL(config).href,
84
+ source,
85
+ error: error instanceof Error ? error.message : String(error),
86
+ offset,
87
+ },
88
+ files,
89
+ };
90
+ }
91
+ }
@@ -1,3 +1,5 @@
1
+ export type { ConnectionTypeDeclarations } from "../vocabulary.js";
2
+ export { ConnectionTypeDeclarationSchema, ConnectionTypeDeclarationsSchema, } from "../vocabulary.js";
1
3
  import { z } from "zod";
2
4
  /** Experimental v1 subset. Fields may be added or refined before v1 stabilizes. */
3
5
  export declare const PositionSchema: z.ZodObject<{
@@ -13,18 +15,10 @@ export declare const ConnectionTypeSchema: z.ZodEnum<{
13
15
  precedes: "precedes";
14
16
  prevents: "prevents";
15
17
  }>;
16
- export type ConnectionType = z.infer<typeof ConnectionTypeSchema>;
18
+ export type ConnectionType = string;
17
19
  export declare const ReferenceSchema: z.ZodObject<{
18
20
  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
- }>>;
21
+ type: z.ZodOptional<z.ZodString>;
28
22
  }, z.core.$strict>;
29
23
  declare const NodeContentSchema: z.ZodObject<{
30
24
  id: z.ZodOptional<z.ZodString>;
@@ -34,6 +28,8 @@ declare const NodeContentSchema: z.ZodObject<{
34
28
  x: z.ZodNumber;
35
29
  y: z.ZodNumber;
36
30
  }, z.core.$strict>>;
31
+ classes: z.ZodOptional<z.ZodArray<z.ZodString>>;
32
+ style: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>]>>;
37
33
  }, z.core.$strict>;
38
34
  export type Reference = z.infer<typeof ReferenceSchema>;
39
35
  export type RichNode = z.infer<typeof NodeContentSchema> & {
@@ -48,7 +44,8 @@ export declare const NodeSchema: z.ZodUnion<readonly [z.ZodString, z.ZodType<Ric
48
44
  export declare const DocumentSchema: z.ZodObject<{
49
45
  version: z.ZodLiteral<"v1">;
50
46
  frontmatter: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
47
+ connection_types: z.ZodOptional<z.ZodType<import("../vocabulary.js").ConnectionTypeDeclarations, unknown, z.core.$ZodTypeInternals<import("../vocabulary.js").ConnectionTypeDeclarations, unknown>>>;
48
+ node_styles: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>]>>>;
51
49
  nodes: z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodType<RichNode, unknown, z.core.$ZodTypeInternals<RichNode, unknown>>]>>;
52
50
  }, z.core.$strict>;
53
51
  export type LensmapDocument = z.infer<typeof DocumentSchema>;
54
- export {};
package/dist/v1/schema.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { NodeStyleSchema } from "../node-style.js";
2
+ import { ConnectionTypeDeclarationsSchema } from "../vocabulary.js";
3
+ export { ConnectionTypeDeclarationSchema, ConnectionTypeDeclarationsSchema, } from "../vocabulary.js";
1
4
  import { z } from "zod";
2
5
  /** Experimental v1 subset. Fields may be added or refined before v1 stabilizes. */
3
6
  export const PositionSchema = z.strictObject({
@@ -14,26 +17,27 @@ export const ConnectionTypeSchema = z.enum([
14
17
  "external_link",
15
18
  "map_link",
16
19
  ]);
17
- const LocalTypeSchema = z.enum([
18
- "connected",
19
- "precedes",
20
- "follows",
21
- "prevents",
22
- "optional",
23
- ]);
20
+ const AuthoredTypeSchema = z.string().regex(/\S/);
24
21
  export const ReferenceSchema = z.strictObject({
25
22
  node_id: IdSchema,
26
- type: ConnectionTypeSchema.optional(),
23
+ type: AuthoredTypeSchema.optional(),
27
24
  });
28
25
  const NodeContentSchema = z.strictObject({
29
26
  id: IdSchema.optional(),
30
27
  summary: z.string().nullable().optional(),
31
28
  full_text: z.string().nullable().optional(),
32
29
  position: PositionSchema.optional(),
30
+ classes: z.array(z.string()).optional(),
31
+ style: NodeStyleSchema.optional(),
33
32
  });
34
33
  const ordinary = (typed) => {
35
34
  const base = typed
36
- ? NodeContentSchema.extend({ type: LocalTypeSchema.optional() })
35
+ ? NodeContentSchema.extend({
36
+ type: z
37
+ .string()
38
+ .regex(/^(?!(?:map_link|external_link)$).*\S/s)
39
+ .optional(),
40
+ })
37
41
  : NodeContentSchema;
38
42
  return z.union([
39
43
  base.extend({
@@ -47,7 +51,7 @@ const ordinary = (typed) => {
47
51
  ]);
48
52
  };
49
53
  const portal = () => NodeContentSchema.extend({
50
- type: z.enum(["map_link", "external_link"]),
54
+ type: AuthoredTypeSchema,
51
55
  url: z.string().regex(/\S/, "A portal URL must not be blank."),
52
56
  connections: z.array(ConnectionSchema).optional(),
53
57
  });
@@ -63,5 +67,7 @@ export const NodeSchema = z.union([z.string(), RootNodeSchema]);
63
67
  export const DocumentSchema = z.strictObject({
64
68
  version: z.literal("v1"),
65
69
  frontmatter: z.record(z.string(), z.unknown()).optional(),
70
+ connection_types: ConnectionTypeDeclarationsSchema.optional(),
71
+ node_styles: z.record(z.string(), NodeStyleSchema).optional(),
66
72
  nodes: z.array(NodeSchema),
67
73
  });
@@ -0,0 +1,67 @@
1
+ import { z } from "zod";
2
+ export interface ConnectionPresentation {
3
+ strokeWidth: number | string;
4
+ lineStyle: "solid" | "dashed" | "dotted";
5
+ lineColor: string;
6
+ startMarker: "none" | "arrow" | "bar" | "circle" | "diamond";
7
+ endMarker: "none" | "arrow" | "bar" | "circle" | "diamond";
8
+ text: string;
9
+ reverseText: string;
10
+ }
11
+ export interface ResolvedConnectionType extends ConnectionPresentation {
12
+ coreKind: "ordinary" | "map_link" | "external_link";
13
+ }
14
+ export declare const ConnectionTypeDeclarationSchema: z.ZodObject<{
15
+ extends: z.ZodOptional<z.ZodString>;
16
+ "stroke-width": z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodString]>>;
17
+ "line-style": z.ZodOptional<z.ZodEnum<{
18
+ dashed: "dashed";
19
+ dotted: "dotted";
20
+ solid: "solid";
21
+ }>>;
22
+ "line-color": z.ZodOptional<z.ZodString>;
23
+ "start-marker": z.ZodOptional<z.ZodEnum<{
24
+ arrow: "arrow";
25
+ bar: "bar";
26
+ circle: "circle";
27
+ diamond: "diamond";
28
+ none: "none";
29
+ }>>;
30
+ "end-marker": z.ZodOptional<z.ZodEnum<{
31
+ arrow: "arrow";
32
+ bar: "bar";
33
+ circle: "circle";
34
+ diamond: "diamond";
35
+ none: "none";
36
+ }>>;
37
+ text: z.ZodOptional<z.ZodString>;
38
+ reverse_text: z.ZodOptional<z.ZodString>;
39
+ }, z.core.$strict>;
40
+ export type ConnectionTypeDeclaration = z.infer<typeof ConnectionTypeDeclarationSchema>;
41
+ export type ConnectionTypeDeclarations = Record<string, z.infer<typeof ConnectionTypeDeclarationSchema>>;
42
+ export declare const ConnectionTypeDeclarationsSchema: z.ZodType<ConnectionTypeDeclarations>;
43
+ export interface VocabularyInput {
44
+ declarations?: ConnectionTypeDeclarations;
45
+ source?: string;
46
+ uri?: string;
47
+ error?: string;
48
+ offset?: number;
49
+ }
50
+ export declare const VocabularySchema: z.ZodObject<{
51
+ connection_types: z.ZodType<ConnectionTypeDeclarations, unknown, z.core.$ZodTypeInternals<ConnectionTypeDeclarations, unknown>>;
52
+ }, z.core.$strict>;
53
+ export declare function parseVocabulary(source: string, uri?: string): VocabularyInput;
54
+ export declare const builtinVocabulary: Record<string, ResolvedConnectionType>;
55
+ export declare function resolveVocabulary(shared?: VocabularyInput, local?: ConnectionTypeDeclarations, localContext?: VocabularyInput): Record<string, ResolvedConnectionType>;
56
+ /** Source-only provenance for host hover/diagnostics; never embedded in edges. */
57
+ export declare function vocabularyLocations(source: string, uri?: string): Record<string, {
58
+ uri?: string;
59
+ name: {
60
+ start: number;
61
+ end: number;
62
+ };
63
+ parent?: {
64
+ start: number;
65
+ end: number;
66
+ };
67
+ }>;
@@ -0,0 +1,530 @@
1
+ import { ident, lexer, parse, walk } from "css-tree";
2
+ import { parseDocument } from "yaml";
3
+ import { z } from "zod";
4
+ function productDimension(a, b, divide) {
5
+ const powers = new Map();
6
+ for (const [dimension, sign] of [
7
+ [a, 1],
8
+ [b, divide ? -1 : 1],
9
+ ]) {
10
+ if (dimension === "number")
11
+ continue;
12
+ for (const term of dimension.split("*")) {
13
+ const [unit, exponent] = term.split("^");
14
+ powers.set(unit, (powers.get(unit) ?? 0) + sign * Number(exponent ?? 1));
15
+ }
16
+ }
17
+ return ([...powers]
18
+ .filter(([, power]) => power !== 0)
19
+ .sort(([a], [b]) => a.localeCompare(b))
20
+ .map(([unit, power]) => (power === 1 ? unit : `${unit}^${power}`))
21
+ .join("*") || "number");
22
+ }
23
+ function mathDimension(node, percentageHint = "length") {
24
+ const fail = () => {
25
+ throw new Error("Invalid CSS math dimensions");
26
+ };
27
+ const compatible = (a, b) => (a === b ? a : fail());
28
+ const expression = (nodes) => {
29
+ if (!nodes.length || nodes.length % 2 === 0)
30
+ return fail();
31
+ const values = [];
32
+ let current = mathDimension(nodes[0], percentageHint);
33
+ for (let i = 1; i < nodes.length; i += 2) {
34
+ const operator = nodes[i];
35
+ if (operator.type !== "Operator")
36
+ return fail();
37
+ const op = operator.value.trim();
38
+ const next = mathDimension(nodes[i + 1], percentageHint);
39
+ if (op === "*" || op === "/") {
40
+ current = productDimension(current, next, op === "/");
41
+ }
42
+ else if (op === "+" || op === "-") {
43
+ values.push(current);
44
+ current = next;
45
+ }
46
+ else
47
+ return fail();
48
+ }
49
+ values.push(current);
50
+ return values.reduce(compatible);
51
+ };
52
+ if (node.type === "Number") {
53
+ if (!Number.isFinite(Number(node.value)))
54
+ return fail();
55
+ return "number";
56
+ }
57
+ if (node.type === "Percentage") {
58
+ if (!Number.isFinite(Number(node.value)))
59
+ return fail();
60
+ return percentageHint;
61
+ }
62
+ if (node.type === "Dimension") {
63
+ if (!Number.isFinite(Number(node.value)))
64
+ return fail();
65
+ for (const unit of [
66
+ "length",
67
+ "angle",
68
+ "time",
69
+ "frequency",
70
+ "resolution",
71
+ "flex",
72
+ ])
73
+ if (lexer.matchType(unit, node).matched)
74
+ return unit;
75
+ return fail();
76
+ }
77
+ if (node.type === "Identifier") {
78
+ if (["e", "pi"].includes(ident.decode(node.name).toLowerCase()))
79
+ return "number";
80
+ return fail();
81
+ }
82
+ if (node.type === "Value" || node.type === "Parentheses")
83
+ return expression(node.children.toArray());
84
+ if (node.type !== "Function")
85
+ return fail();
86
+ const groups = [[]];
87
+ for (const child of node.children) {
88
+ if (child.type === "Operator" && child.value.trim() === ",")
89
+ groups.push([]);
90
+ else
91
+ groups[groups.length - 1].push(child);
92
+ }
93
+ const fn = ident.decode(node.name).toLowerCase();
94
+ if (fn === "round" &&
95
+ groups[0].length === 1 &&
96
+ groups[0][0].type === "Identifier" &&
97
+ ["nearest", "up", "down", "to-zero"].includes(groups[0][0].name.toLowerCase()))
98
+ groups.shift();
99
+ const args = groups.map(expression);
100
+ const arity = (min, max = min) => {
101
+ if (args.length < min || args.length > max)
102
+ fail();
103
+ };
104
+ if (fn === "calc") {
105
+ arity(1);
106
+ return args[0];
107
+ }
108
+ if (["min", "max", "hypot"].includes(fn)) {
109
+ arity(1, Infinity);
110
+ return args.reduce(compatible);
111
+ }
112
+ if (fn === "clamp") {
113
+ arity(3);
114
+ return args.reduce(compatible);
115
+ }
116
+ if (["mod", "rem"].includes(fn)) {
117
+ arity(2);
118
+ return args.reduce(compatible);
119
+ }
120
+ if (fn === "round") {
121
+ arity(1, 2);
122
+ if (args.length === 1 && args[0] !== "number")
123
+ return fail();
124
+ return args.reduce(compatible);
125
+ }
126
+ if (fn === "abs") {
127
+ arity(1);
128
+ return args[0];
129
+ }
130
+ if (fn === "sign") {
131
+ arity(1);
132
+ return "number";
133
+ }
134
+ if (["sin", "cos", "tan"].includes(fn)) {
135
+ arity(1);
136
+ if (!["number", "angle"].includes(args[0]))
137
+ return fail();
138
+ return "number";
139
+ }
140
+ if (["asin", "acos", "atan"].includes(fn)) {
141
+ arity(1);
142
+ if (args[0] !== "number")
143
+ return fail();
144
+ return "angle";
145
+ }
146
+ if (fn === "atan2") {
147
+ arity(2);
148
+ args.reduce(compatible);
149
+ return "angle";
150
+ }
151
+ if (["pow", "sqrt", "log", "exp"].includes(fn)) {
152
+ arity(fn === "pow" ? 2 : 1, fn === "log" ? 2 : fn === "pow" ? 2 : 1);
153
+ if (args.some((a) => a !== "number"))
154
+ return fail();
155
+ return "number";
156
+ }
157
+ return fail();
158
+ }
159
+ function css(value, property) {
160
+ try {
161
+ const ast = parse(value, { context: "value" });
162
+ let safe = true;
163
+ walk(ast, (node) => {
164
+ if (property === "color" && node.type === "Function") {
165
+ const fn = ident.decode(node.name).toLowerCase();
166
+ if ([
167
+ "rgb",
168
+ "rgba",
169
+ "hsl",
170
+ "hsla",
171
+ "hwb",
172
+ "lab",
173
+ "lch",
174
+ "oklab",
175
+ "oklch",
176
+ "color",
177
+ "device-cmyk",
178
+ ].includes(fn)) {
179
+ const channels = node.children
180
+ .toArray()
181
+ .filter((child) => child.type !== "Operator");
182
+ const hue = ["hsl", "hsla", "hwb"].includes(fn)
183
+ ? 0
184
+ : ["lch", "oklch"].includes(fn)
185
+ ? 2
186
+ : -1;
187
+ channels.forEach((channel, index) => {
188
+ if (channel.type === "Function" &&
189
+ [
190
+ "calc",
191
+ "min",
192
+ "max",
193
+ "clamp",
194
+ "round",
195
+ "mod",
196
+ "rem",
197
+ "abs",
198
+ "sign",
199
+ "sin",
200
+ "cos",
201
+ "tan",
202
+ "asin",
203
+ "acos",
204
+ "atan",
205
+ "atan2",
206
+ "pow",
207
+ "sqrt",
208
+ "hypot",
209
+ "log",
210
+ "exp",
211
+ ].includes(ident.decode(channel.name).toLowerCase())) {
212
+ const dimension = mathDimension(channel, index === hue ? "percentage" : "number");
213
+ if (index === hue
214
+ ? !["number", "angle"].includes(dimension)
215
+ : dimension !== "number")
216
+ safe = false;
217
+ }
218
+ });
219
+ }
220
+ }
221
+ if (property === "color" &&
222
+ node.type === "Function" &&
223
+ [
224
+ "calc",
225
+ "min",
226
+ "max",
227
+ "clamp",
228
+ "round",
229
+ "mod",
230
+ "rem",
231
+ "abs",
232
+ "sign",
233
+ "sin",
234
+ "cos",
235
+ "tan",
236
+ "asin",
237
+ "acos",
238
+ "atan",
239
+ "atan2",
240
+ "pow",
241
+ "sqrt",
242
+ "hypot",
243
+ "log",
244
+ "exp",
245
+ ].includes(ident.decode(node.name).toLowerCase()))
246
+ mathDimension(node, "number");
247
+ if (node.type === "Raw" || node.type === "Url")
248
+ safe = false;
249
+ if (node.type === "Function" &&
250
+ ["var", "env", "attr", "url", "paint"].includes(ident.decode(node.name).toLowerCase()))
251
+ safe = false;
252
+ if (node.type === "Identifier" &&
253
+ (/^(inherit|initial|unset|revert|revert-layer|infinity|-infinity|nan)$/i.test(node.name) ||
254
+ ident.decode(node.name).startsWith("--")))
255
+ safe = false;
256
+ });
257
+ if (!safe || !lexer.matchProperty(property, ast).matched)
258
+ return false;
259
+ if (property === "stroke-width" &&
260
+ !["number", "length"].includes(mathDimension(ast)))
261
+ return false;
262
+ if (property === "stroke-width" && ast.type === "Value") {
263
+ const literal = ast.children.first;
264
+ if (literal &&
265
+ ["Number", "Dimension", "Percentage"].includes(literal.type) &&
266
+ "value" in literal &&
267
+ Number(literal.value) < 0)
268
+ return false;
269
+ }
270
+ return true;
271
+ }
272
+ catch {
273
+ return false;
274
+ }
275
+ }
276
+ const name = z.string().regex(/\S/);
277
+ const marker = z.enum(["none", "arrow", "bar", "circle", "diamond"]);
278
+ export const ConnectionTypeDeclarationSchema = z
279
+ .strictObject({
280
+ extends: name.optional(),
281
+ "stroke-width": z
282
+ .union([
283
+ z.number().finite().nonnegative(),
284
+ z
285
+ .string()
286
+ .refine((v) => css(v, "stroke-width"), "Invalid scalar CSS width"),
287
+ ])
288
+ .optional(),
289
+ "line-style": z.enum(["solid", "dashed", "dotted"]).optional(),
290
+ "line-color": z
291
+ .string()
292
+ .refine((v) => v === "auto" || css(v, "color"), "Invalid CSS colour")
293
+ .optional(),
294
+ "start-marker": marker.optional(),
295
+ "end-marker": marker.optional(),
296
+ text: name.optional(),
297
+ reverse_text: name.optional(),
298
+ })
299
+ .refine((v) => (v.text === undefined) === (v.reverse_text === undefined), "Supply both text and reverse_text")
300
+ .meta({
301
+ anyOf: [
302
+ { required: ["text", "reverse_text"] },
303
+ {
304
+ not: {
305
+ anyOf: [{ required: ["text"] }, { required: ["reverse_text"] }],
306
+ },
307
+ },
308
+ ],
309
+ });
310
+ // Zod records intentionally discard __proto__; validate entries without copying
311
+ // through an ordinary object's property setter so every exact authored name survives.
312
+ export const ConnectionTypeDeclarationsSchema = z
313
+ .any()
314
+ .superRefine((value, ctx) => {
315
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
316
+ ctx.addIssue({
317
+ code: "custom",
318
+ message: "Expected a declaration mapping",
319
+ });
320
+ return;
321
+ }
322
+ for (const [key, declaration] of Object.entries(value)) {
323
+ if (!name.safeParse(key).success)
324
+ ctx.addIssue({
325
+ code: "custom",
326
+ path: [key],
327
+ message: "Type names must be nonblank",
328
+ });
329
+ const result = ConnectionTypeDeclarationSchema.safeParse(declaration);
330
+ if (!result.success)
331
+ for (const issue of result.error.issues)
332
+ ctx.addIssue({ ...issue, path: [key, ...issue.path] });
333
+ }
334
+ })
335
+ .meta({
336
+ type: "object",
337
+ propertyNames: { type: "string", pattern: "\\S" },
338
+ additionalProperties: z.toJSONSchema(ConnectionTypeDeclarationSchema, {
339
+ io: "input",
340
+ }),
341
+ });
342
+ export const VocabularySchema = z.strictObject({
343
+ connection_types: ConnectionTypeDeclarationsSchema,
344
+ });
345
+ export function parseVocabulary(source, uri) {
346
+ try {
347
+ const doc = parseDocument(source, { uniqueKeys: true });
348
+ const problem = [...doc.errors, ...doc.warnings][0];
349
+ if (problem)
350
+ return { source, uri, error: problem.message, offset: problem.pos[0] };
351
+ const result = VocabularySchema.safeParse(doc.toJS({ maxAliasCount: 100 }));
352
+ if (!result.success) {
353
+ const issue = result.error.issues[0];
354
+ const ast = doc.getIn(issue.path, true);
355
+ return {
356
+ source,
357
+ uri,
358
+ error: issue.message,
359
+ offset: ast?.range?.[0] ?? 0,
360
+ };
361
+ }
362
+ const value = result.data;
363
+ return { source, uri, declarations: value.connection_types };
364
+ }
365
+ catch (error) {
366
+ return {
367
+ source,
368
+ uri,
369
+ error: error instanceof Error ? error.message : String(error),
370
+ offset: 0,
371
+ };
372
+ }
373
+ }
374
+ const neutral = {
375
+ strokeWidth: 2.5,
376
+ lineStyle: "solid",
377
+ lineColor: "auto",
378
+ startMarker: "none",
379
+ endMarker: "none",
380
+ text: "connected to",
381
+ reverseText: "connected to",
382
+ coreKind: "ordinary",
383
+ };
384
+ export const builtinVocabulary = Object.fromEntries([
385
+ ["connected", {}],
386
+ [
387
+ "precedes",
388
+ { endMarker: "arrow", text: "precedes", reverseText: "follows" },
389
+ ],
390
+ [
391
+ "follows",
392
+ { startMarker: "arrow", text: "follows", reverseText: "precedes" },
393
+ ],
394
+ [
395
+ "prevents",
396
+ { endMarker: "bar", text: "prevents", reverseText: "is prevented by" },
397
+ ],
398
+ [
399
+ "optional",
400
+ {
401
+ lineStyle: "dashed",
402
+ text: "optionally connected to",
403
+ reverseText: "optionally connected to",
404
+ },
405
+ ],
406
+ [
407
+ "map_link",
408
+ {
409
+ coreKind: "map_link",
410
+ text: "links to map",
411
+ reverseText: "map linked from",
412
+ },
413
+ ],
414
+ [
415
+ "external_link",
416
+ {
417
+ coreKind: "external_link",
418
+ text: "links to resource",
419
+ reverseText: "resource linked from",
420
+ },
421
+ ],
422
+ ].map(([key, value]) => [key, { ...neutral, ...value }]));
423
+ export function resolveVocabulary(shared = {}, local = {}, localContext = {}) {
424
+ if (shared.error)
425
+ throw Object.assign(new Error(shared.error), {
426
+ offset: shared.offset ?? 0,
427
+ uri: shared.uri,
428
+ source: shared.source,
429
+ });
430
+ const layers = [
431
+ ConnectionTypeDeclarationsSchema.parse(shared.declarations ?? {}),
432
+ ConnectionTypeDeclarationsSchema.parse(local),
433
+ ];
434
+ const declarations = new Map();
435
+ for (const layer of layers)
436
+ for (const [key, value] of Object.entries(layer))
437
+ declarations.set(key, { ...declarations.get(key), ...value });
438
+ const fail = (key, message) => {
439
+ const parentIsLocal = Object.hasOwn(local, key) &&
440
+ (declarations.get(key)?.extends === undefined ||
441
+ Object.hasOwn(local[key], "extends"));
442
+ const context = parentIsLocal ? localContext : shared;
443
+ const location = context.source
444
+ ? vocabularyLocations(context.source, context.uri)[key]
445
+ : undefined;
446
+ throw Object.assign(new Error(message), {
447
+ uri: context.uri,
448
+ source: context.source,
449
+ offset: location?.parent?.start ?? location?.name.start ?? 0,
450
+ });
451
+ };
452
+ const resolved = new Map();
453
+ const visiting = new Set();
454
+ const resolve = (key) => {
455
+ const existing = resolved.get(key);
456
+ if (existing)
457
+ return existing;
458
+ if (visiting.has(key))
459
+ fail(key, `Connection type inheritance cycle: ${key}`);
460
+ const seed = Object.hasOwn(builtinVocabulary, key)
461
+ ? builtinVocabulary[key]
462
+ : undefined;
463
+ const authored = declarations.get(key);
464
+ if (!seed && !authored)
465
+ throw new Error(`Unknown connection type: ${key}`);
466
+ if (authored?.extends !== undefined &&
467
+ !declarations.has(authored.extends) &&
468
+ !Object.hasOwn(builtinVocabulary, authored.extends))
469
+ fail(key, `Unknown parent connection type: ${authored.extends}`);
470
+ visiting.add(key);
471
+ const base = authored?.extends !== undefined
472
+ ? resolve(authored.extends)
473
+ : (seed ?? resolve("connected"));
474
+ if (seed && seed.coreKind !== base.coreKind)
475
+ fail(key, `Cannot change built-in core capability: ${key}`);
476
+ const value = { ...base };
477
+ const fields = {
478
+ "stroke-width": "strokeWidth",
479
+ "line-style": "lineStyle",
480
+ "line-color": "lineColor",
481
+ "start-marker": "startMarker",
482
+ "end-marker": "endMarker",
483
+ text: "text",
484
+ reverse_text: "reverseText",
485
+ };
486
+ for (const [field, target] of Object.entries(fields))
487
+ if (authored && Object.hasOwn(authored, field))
488
+ Object.assign(value, {
489
+ [target]: authored[field],
490
+ });
491
+ visiting.delete(key);
492
+ resolved.set(key, value);
493
+ return value;
494
+ };
495
+ for (const key of new Set([
496
+ ...Object.keys(builtinVocabulary),
497
+ ...declarations.keys(),
498
+ ]))
499
+ resolve(key);
500
+ return Object.fromEntries(resolved);
501
+ }
502
+ /** Source-only provenance for host hover/diagnostics; never embedded in edges. */
503
+ export function vocabularyLocations(source, uri) {
504
+ const doc = parseDocument(source);
505
+ const mapping = doc.getIn(["connection_types"], true);
506
+ const entries = [];
507
+ if (mapping &&
508
+ typeof mapping === "object" &&
509
+ "items" in mapping &&
510
+ Array.isArray(mapping.items))
511
+ for (const pair of mapping.items) {
512
+ if (!pair || typeof pair !== "object" || !("key" in pair))
513
+ continue;
514
+ const key = pair.key;
515
+ if (!key || !("value" in key) || typeof key.value !== "string")
516
+ continue;
517
+ const parent = doc.getIn(["connection_types", key.value, "extends"], true);
518
+ entries.push([
519
+ key.value,
520
+ {
521
+ uri,
522
+ name: { start: key.range?.[0] ?? 0, end: key.range?.[1] ?? 0 },
523
+ ...(parent?.range
524
+ ? { parent: { start: parent.range[0], end: parent.range[1] } }
525
+ : {}),
526
+ },
527
+ ]);
528
+ }
529
+ return Object.fromEntries(entries);
530
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
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.2.0",
5
+ "version": "0.3.0",
6
6
  "files": [
7
7
  "dist"
8
8
  ],
@@ -28,13 +28,19 @@
28
28
  "./v1": {
29
29
  "types": "./dist/v1/schema.d.ts",
30
30
  "default": "./dist/v1/schema.js"
31
+ },
32
+ "./project": {
33
+ "types": "./dist/project.d.ts",
34
+ "default": "./dist/project.js"
31
35
  }
32
36
  },
33
37
  "dependencies": {
38
+ "css-tree": "3.2.1",
34
39
  "yaml": "2.9.1",
35
40
  "zod": "4.6.7"
36
41
  },
37
42
  "devDependencies": {
43
+ "@types/css-tree": "3.2.0",
38
44
  "@types/node": "24.19.2",
39
45
  "ajv": "8.20.0",
40
46
  "typescript": "7.0.2",