@coldsmirk/inkstone-codemirror 0.8.3 → 0.10.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
@@ -1,16 +1,18 @@
1
1
  # @coldsmirk/inkstone-codemirror
2
2
 
3
- Framework-agnostic [CodeMirror 6](https://codemirror.net/) plumbing: a controlled editor host (create-once, external-value reconcile, in-place theme swap), the standard document extension bundle, lazy per-language grammar loading, and precise [MiniJinja](https://github.com/mitsuhiko/minijinja) template support with schema-driven context completion.
3
+ Framework-agnostic [CodeMirror 6](https://codemirror.net/) plumbing: controlled editor and merge-view hosts (create-once, external-value reconcile, in-place theme swap), the standard document extension bundle, lazy per-language grammar loading, and precise [MiniJinja](https://github.com/mitsuhiko/minijinja) template support with schema-driven context completion.
4
4
 
5
5
  Part of [inkstone](https://github.com/coldsmirk/inkstone). For a drop-in React component use [`@coldsmirk/inkstone-react`](https://www.npmjs.com/package/@coldsmirk/inkstone-react)'s `<CodeMirrorEditor>`, which is built on this package; reach for this layer to wire the same plumbing into your own components (any framework).
6
6
 
7
7
  ## What you get
8
8
 
9
9
  - **`ControlledEditorHost`** — the controlled-editor lifecycle solved once: the view is created once, external value changes reconcile into the document without resetting cursor/undo, and theme/extension swaps happen in place.
10
+ - **`ControlledMergeHost`** — the same discipline for [`@codemirror/merge`](https://github.com/codemirror/merge)'s side-by-side `MergeView`: both documents reconcile per side without caret resets or callback echo, dynamic extensions swap in place on both editors, and every merge-level option (`orientation`, `revertControls`, `collapseUnchanged`, …) passes straight through. Policy-free — pin either side read-only via `originalExtensions` / `modifiedExtensions` to match how your documents are controlled.
10
11
  - **`documentExtensions(options)`** — the standard document editing bundle (history, brackets, completion UI, optional line numbers / wrapping / search), so hosts start from working defaults instead of a bare `EditorState`.
11
12
  - **`codeMirrorLanguages` / `loadLanguage(language)`** — the supported `CodeMirrorLanguage` names and their lazy loaders: one dynamic `import()` per grammar, so only the selected language is fetched, never the whole catalog. Two tiers back the ids: the official `@codemirror/lang-*` (Lezer) parsers where upstream ships one, plus the full [`@codemirror/legacy-modes`](https://github.com/codemirror/legacy-modes) catalog (stream-mode highlighting) for everything upstream never wrote a Lezer grammar for — `shell`, `dockerfile`, `ini` / `env` / `properties`, `toml`, `nginx`, `powershell`, `lua`, and a hundred more.
12
- - **MiniJinja** — `language: "minijinja"` for standalone templates, or `minijinja-<host>` mixed variants (`minijinja-html`, `-xml`, `-json`, `-yaml`, `-css`, `-scss`, `-sass`, `-less`, `-markdown`; the SQL family — `-sql` plus every dialect id: `-mysql`, `-pgsql`, `-sqlite`, `-mssql`, `-mariadb`, `-plsql`, `-cassandra`, `-hive`, `-sparksql`, `-gql`, `-gpsql`, `-esper`; and the config/deploy hosts `-toml`, `-ini`, `-env`, `-shell`, `-dockerfile`, `-nginx`) that overlay MiniJinja tooling on the host grammar. A linter flags non-MiniJinja tags; autocomplete is restricted to MiniJinja's real tags / filters / tests / functions.
13
+ - **MiniJinja** — `language: "minijinja"` for standalone templates, or `minijinja-<host>` mixed variants (`minijinja-html`, `-xml`, `-json`, `-yaml`, `-css`, `-scss`, `-sass`, `-less`, `-markdown`; the SQL family — `-sql` plus every dialect id: `-mysql`, `-pgsql`, `-sqlite`, `-mssql`, `-mariadb`, `-plsql`, `-cassandra`, `-hive`, `-sparksql`, `-gql`, `-gpsql`, `-esper`; and the config/deploy hosts `-toml`, `-ini`, `-env`, `-shell`, `-dockerfile`, `-nginx`) that overlay MiniJinja tooling on the host grammar. A parser adapter supports MiniJinja whitespace control, dotted namespace assignments, and comma-separated `set` assignments beyond the upstream Jinja grammar; a linter flags non-MiniJinja tags, and autocomplete is restricted to MiniJinja's real tags / filters / tests / functions.
13
14
  - **Context completion** — `normalizeContext({ schema })` (JSON Schema) or `normalizeContext({ sample })` (representative value) builds an `EditorContext`; dispatch it with `setMinijinjaContext` to make `{{ }}` / `{% if %}` / `{% for %}` complete the render context's variables and walk member access (`user.address.city`). `minijinjaContextField`, `resolveMembers`, and `typeAtPath` expose the underlying pieces.
15
+ - **SQL schema completion** — dispatch `setSqlSchema.of({ tables })` for dialect-aware table and column completion in the Lezer-backed `sql`, `mysql`, `pgsql`, `sqlite`, `mssql`, `mariadb`, `plsql`, and `cassandra` languages plus their `minijinja-*` variants. Legacy stream-mode SQL languages do not consume schemas. Controlled hosts install the data channel eagerly, so the schema may arrive before the lazy grammar.
14
16
  - **`searchPhrasesZhCn`** — an opt-in `EditorState.phrases` table localizing the search panel to Simplified Chinese.
15
17
 
16
18
  ## Install
@@ -20,7 +22,7 @@ pnpm add @coldsmirk/inkstone-codemirror \
20
22
  @codemirror/state @codemirror/view @codemirror/language @codemirror/autocomplete @codemirror/commands @codemirror/search
21
23
  ```
22
24
 
23
- The `@codemirror/*` packages are **peer dependencies** on purpose: `@codemirror/state` breaks at runtime if two copies load, so your app owns the single copy and every extension is guaranteed to be built against it. Node.js >= 22 for build / SSR hosts.
25
+ The `@codemirror/*` packages are **peer dependencies** on purpose: `@codemirror/state` breaks at runtime if two copies load, so your app owns the single copy and every extension is guaranteed to be built against it. Node.js >= 24 for build / SSR hosts.
24
26
 
25
27
  ## Quick start
26
28
 
@@ -38,6 +40,29 @@ const host = new ControlledEditorHost({
38
40
  host.setValue(remoteValue);
39
41
  ```
40
42
 
43
+ For a side-by-side diff:
44
+
45
+ ```ts
46
+ import { EditorState } from "@codemirror/state";
47
+ import { EditorView } from "@codemirror/view";
48
+ import { ControlledMergeHost, documentExtensions } from "@coldsmirk/inkstone-codemirror";
49
+
50
+ const diff = new ControlledMergeHost({
51
+ parent: document.querySelector("#diff")!,
52
+ original: before,
53
+ modified: after,
54
+ extensions: [documentExtensions({ showLineNumbers: true })],
55
+ // The original side follows setOriginal only — no change channel, so lock it.
56
+ originalExtensions: [EditorState.readOnly.of(true), EditorView.editable.of(false)],
57
+ onModifiedChange: next => save(next),
58
+ revertControls: "a-to-b"
59
+ });
60
+
61
+ // Both sides reconcile externally, same rules as setValue.
62
+ diff.setOriginal(nextBefore);
63
+ diff.setModified(nextAfter);
64
+ ```
65
+
41
66
  For MiniJinja with context completion:
42
67
 
43
68
  ```ts
@@ -47,6 +72,11 @@ import { loadLanguage, normalizeContext, setMinijinjaContext } from "@coldsmirk/
47
72
  view.dispatch({ effects: setMinijinjaContext.of(normalizeContext({ schema })) });
48
73
  ```
49
74
 
75
+ The controlled hosts install both context and SQL-schema fields before any grammar resolves, so
76
+ applications may dispatch either effect immediately and load/reconfigure the grammar later.
77
+ Hand-built `EditorState` instances should install `minijinjaContextField` / `sqlSchemaField`
78
+ explicitly when they need the same ordering.
79
+
50
80
  The completion is **structural** — it walks the declared shape (member access along a literal path), not types produced by filters or arithmetic. For a Rust backend the schema is nearly free: derive it from the same `serde` struct you render with via [`schemars`](https://docs.rs/schemars), and the completion can't drift from the real context.
51
81
 
52
82
  ## License
@@ -0,0 +1,389 @@
1
+ import { StateEffect, StateField } from "@codemirror/state";
2
+ //#region src/context.ts
3
+ const ANY = { kind: "any" };
4
+ const NULL = { kind: "null" };
5
+ const MAX_DEREF = 100;
6
+ const setMinijinjaContext = StateEffect.define();
7
+ const minijinjaContextField = StateField.define({
8
+ create() {
9
+ return null;
10
+ },
11
+ update(value, transaction) {
12
+ let next = value;
13
+ for (const effect of transaction.effects) if (effect.is(setMinijinjaContext)) next = effect.value;
14
+ return next;
15
+ }
16
+ });
17
+ function normalizeContext(input) {
18
+ if ("schema" in input) return fromJsonSchema(input.schema);
19
+ return {
20
+ root: fromSample(input.sample),
21
+ defs: {}
22
+ };
23
+ }
24
+ function resolveMembers(ir, path, scope) {
25
+ if (path.length === 0) {
26
+ const top = membersOf(ir, ir.root);
27
+ if (!scope || scope.size === 0) return top;
28
+ return [...[...scope].map(([name, type]) => {
29
+ return {
30
+ name,
31
+ type,
32
+ required: true,
33
+ nullable: false
34
+ };
35
+ }), ...top.filter((member) => !scope.has(member.name))];
36
+ }
37
+ const type = typeAtPath(ir, path, scope);
38
+ return type ? membersOf(ir, type) : [];
39
+ }
40
+ function typeAtPath(ir, path, scope) {
41
+ const [first, ...rest] = path;
42
+ if (first === void 0) return deref(ir, ir.root);
43
+ let type = scope?.get(first) ?? stepInto(ir, ir.root, first);
44
+ for (const key of rest) {
45
+ if (type === null) return null;
46
+ type = stepInto(ir, type, key);
47
+ }
48
+ return type === null ? null : deref(ir, type);
49
+ }
50
+ function elementType(ir, type) {
51
+ const resolved = deref(ir, type);
52
+ return resolved.kind === "array" ? deref(ir, resolved.element) : null;
53
+ }
54
+ function followRefs(type, lookup) {
55
+ let current = type;
56
+ for (let i = 0; i < MAX_DEREF && current.kind === "ref"; i++) {
57
+ const next = lookup(current.name);
58
+ if (!next) return null;
59
+ current = next;
60
+ }
61
+ return current.kind === "ref" ? null : current;
62
+ }
63
+ function deref(ir, type) {
64
+ return followRefs(type, (name) => ir.defs[name] ?? null) ?? ANY;
65
+ }
66
+ function stepInto(ir, type, key) {
67
+ const resolved = deref(ir, type);
68
+ if (resolved.kind !== "object") return null;
69
+ const field = resolved.fields[key];
70
+ return field ? field.type : null;
71
+ }
72
+ function membersOf(ir, type) {
73
+ const resolved = deref(ir, type);
74
+ if (resolved.kind !== "object") return [];
75
+ return Object.entries(resolved.fields).map(([name, field]) => {
76
+ return {
77
+ name,
78
+ type: field.type,
79
+ required: field.required,
80
+ nullable: field.nullable,
81
+ doc: field.doc
82
+ };
83
+ });
84
+ }
85
+ const REF_PREFIXES = ["#/$defs/", "#/definitions/"];
86
+ function fromJsonSchema(schema) {
87
+ if (!isObject(schema)) return {
88
+ root: ANY,
89
+ defs: {}
90
+ };
91
+ const context = {
92
+ raw: {
93
+ ...asObject(schema.definitions),
94
+ ...asObject(schema.$defs)
95
+ },
96
+ defs: Object.create(null),
97
+ resolving: /* @__PURE__ */ new Set(),
98
+ tainted: /* @__PURE__ */ new Set(),
99
+ cyclic: /* @__PURE__ */ new Set(),
100
+ dependents: /* @__PURE__ */ new Map(),
101
+ converting: []
102
+ };
103
+ for (const name of Object.keys(context.raw)) defType(name, context);
104
+ if (context.tainted.size > 0) {
105
+ const queue = [...context.tainted];
106
+ const pending = new Set(queue);
107
+ let head = 0;
108
+ let budget = (Object.keys(context.raw).length + 1) ** 2;
109
+ while (head < queue.length && budget > 0) {
110
+ budget -= 1;
111
+ const name = queue[head];
112
+ head += 1;
113
+ if (name === void 0) break;
114
+ pending.delete(name);
115
+ context.converting.push(name);
116
+ const next = convert(context.raw[name], context);
117
+ context.converting.pop();
118
+ if (JSON.stringify(next) !== JSON.stringify(context.defs[name])) {
119
+ context.defs[name] = next;
120
+ const stale = [...context.dependents.get(name) ?? []].filter((reader) => !pending.has(reader));
121
+ for (const dependent of stale) {
122
+ pending.add(dependent);
123
+ queue.push(dependent);
124
+ }
125
+ }
126
+ }
127
+ }
128
+ return {
129
+ root: convert(schema, context),
130
+ defs: context.defs
131
+ };
132
+ }
133
+ function defType(name, context) {
134
+ const reader = context.converting.at(-1);
135
+ if (reader !== void 0 && reader !== name) {
136
+ const readers = context.dependents.get(name);
137
+ if (readers) readers.add(reader);
138
+ else context.dependents.set(name, /* @__PURE__ */ new Set([reader]));
139
+ }
140
+ if (Object.hasOwn(context.defs, name)) return context.defs[name] ?? null;
141
+ if (context.resolving.has(name)) {
142
+ for (const pending of context.converting) context.tainted.add(pending);
143
+ const cycleStart = context.converting.lastIndexOf(name);
144
+ if (cycleStart === -1) context.cyclic.add(name);
145
+ else for (const member of context.converting.slice(cycleStart)) context.cyclic.add(member);
146
+ return null;
147
+ }
148
+ if (!Object.hasOwn(context.raw, name)) return null;
149
+ context.resolving.add(name);
150
+ context.converting.push(name);
151
+ const type = convert(context.raw[name], context);
152
+ context.converting.pop();
153
+ context.resolving.delete(name);
154
+ context.defs[name] = type;
155
+ return type;
156
+ }
157
+ function convert(node, context) {
158
+ if (typeof node === "boolean" || !isObject(node)) return ANY;
159
+ const ref = refName(node.$ref);
160
+ const reference = ref === null ? null : {
161
+ kind: "ref",
162
+ name: ref
163
+ };
164
+ if (Array.isArray(node.allOf)) {
165
+ const branches = node.allOf.map((entry) => convert(entry, context));
166
+ if (reference) branches.unshift(reference);
167
+ return requireFields(mergeAll(isObject(node.properties) ? [...branches, objectType(node, context)] : branches, context, "all"), node.required, context);
168
+ }
169
+ const union = node.anyOf ?? node.oneOf;
170
+ if (Array.isArray(union)) {
171
+ const viable = union.filter((branch) => unionBranchKind(branch, context) !== "impossible");
172
+ const nonNull = viable.filter((branch) => unionBranchKind(branch, context) !== "null");
173
+ if (viable.length === 0) return ANY;
174
+ if (nonNull.length === 0) return NULL;
175
+ const merged = nonNull.length === 1 ? convert(nonNull[0], context) : mergeAll(nonNull.map((entry) => convert(entry, context)), context, "any");
176
+ const withOwn = isObject(node.properties) ? mergeAll([merged, objectType(node, context)], context, "all") : merged;
177
+ return requireFields(reference ? mergeAll([reference, withOwn], context, "all") : withOwn, node.required, context);
178
+ }
179
+ if (reference) {
180
+ const hasProperties = isObject(node.properties);
181
+ if (!(hasProperties || Array.isArray(node.required) || primaryType(node) === "object")) return reference;
182
+ return requireFields(hasProperties ? mergeAll([reference, objectType(node, context)], context, "all") : reference, node.required, context);
183
+ }
184
+ if (Array.isArray(node.enum)) return {
185
+ kind: "enum",
186
+ values: node.enum.filter(isLiteral)
187
+ };
188
+ if ("const" in node && isLiteral(node.const)) return {
189
+ kind: "enum",
190
+ values: [node.const]
191
+ };
192
+ const type = primaryType(node);
193
+ if (isObjectShape(node)) return objectType(node, context);
194
+ if (type === "array") return {
195
+ kind: "array",
196
+ element: convert((Array.isArray(node.prefixItems) ? node.prefixItems[0] : void 0) ?? (Array.isArray(node.items) ? node.items[0] : node.items), context)
197
+ };
198
+ if (type === "string") return { kind: "string" };
199
+ if (type === "integer" || type === "number") return { kind: "number" };
200
+ if (type === "boolean") return { kind: "boolean" };
201
+ if (type === "null") return NULL;
202
+ return ANY;
203
+ }
204
+ function isObjectShape(node) {
205
+ return primaryType(node) === "object" || isObject(node.properties);
206
+ }
207
+ function objectType(node, context) {
208
+ const properties = asObject(node.properties);
209
+ const required = new Set(Array.isArray(node.required) ? node.required.filter((name) => typeof name === "string") : []);
210
+ const fields = Object.create(null);
211
+ for (const [name, sub] of Object.entries(properties)) fields[name] = {
212
+ type: convert(sub, context),
213
+ required: required.has(name),
214
+ nullable: isNullable(sub, context),
215
+ doc: description(sub)
216
+ };
217
+ return {
218
+ kind: "object",
219
+ fields
220
+ };
221
+ }
222
+ function mergeAll(types, context, mode) {
223
+ const [only] = types;
224
+ if (only && types.length === 1) return only;
225
+ const fields = Object.create(null);
226
+ const appearances = /* @__PURE__ */ new Map();
227
+ let branches = 0;
228
+ for (const type of types) {
229
+ const resolved = derefFully(type, context);
230
+ if (resolved?.kind === "object") {
231
+ branches += 1;
232
+ for (const [name, field] of Object.entries(resolved.fields)) {
233
+ const existing = fields[name];
234
+ fields[name] = existing ? mergeField(existing, field, mode) : field;
235
+ appearances.set(name, (appearances.get(name) ?? 0) + 1);
236
+ }
237
+ }
238
+ }
239
+ if (branches > 0) {
240
+ if (mode === "any") {
241
+ for (const [name, field] of Object.entries(fields)) if (field.required && appearances.get(name) !== types.length) fields[name] = {
242
+ ...field,
243
+ required: false
244
+ };
245
+ }
246
+ return {
247
+ kind: "object",
248
+ fields
249
+ };
250
+ }
251
+ return types.find((type) => type.kind !== "any") ?? ANY;
252
+ }
253
+ function requireFields(type, required, context) {
254
+ const names = Array.isArray(required) ? required.filter((name) => typeof name === "string") : [];
255
+ if (names.length === 0) return type;
256
+ const resolved = type.kind === "ref" ? derefNonCyclic(type, context) : type;
257
+ if (resolved === null || resolved.kind !== "object") return type;
258
+ const fields = Object.assign(Object.create(null), resolved.fields);
259
+ let changed = false;
260
+ for (const name of names) {
261
+ const field = fields[name];
262
+ if (field && !field.required) {
263
+ fields[name] = {
264
+ ...field,
265
+ required: true
266
+ };
267
+ changed = true;
268
+ }
269
+ }
270
+ return changed ? {
271
+ kind: "object",
272
+ fields
273
+ } : type;
274
+ }
275
+ function mergeField(existing, incoming, mode) {
276
+ return {
277
+ type: incoming.type,
278
+ required: mode === "all" ? existing.required || incoming.required : existing.required && incoming.required,
279
+ nullable: mode === "all" ? existing.nullable && incoming.nullable : existing.nullable || incoming.nullable,
280
+ doc: incoming.doc ?? existing.doc
281
+ };
282
+ }
283
+ function derefFully(type, context) {
284
+ return followRefs(type, (name) => defType(name, context));
285
+ }
286
+ function derefNonCyclic(type, context) {
287
+ let current = type;
288
+ for (let i = 0; i < MAX_DEREF && current.kind === "ref"; i++) {
289
+ if (context.cyclic.has(current.name)) return null;
290
+ const next = defType(current.name, context);
291
+ if (!next) return null;
292
+ current = next;
293
+ }
294
+ return current.kind === "ref" ? null : current;
295
+ }
296
+ function fromSample(value, ancestors = /* @__PURE__ */ new WeakSet()) {
297
+ if (value === null || value === void 0) return ANY;
298
+ if (typeof value === "object") {
299
+ if (ancestors.has(value)) return ANY;
300
+ ancestors.add(value);
301
+ const type = Array.isArray(value) ? sampleArrayType(value, ancestors) : sampleObjectType(value, ancestors);
302
+ ancestors.delete(value);
303
+ return type;
304
+ }
305
+ if (typeof value === "string") return { kind: "string" };
306
+ if (typeof value === "number") return { kind: "number" };
307
+ if (typeof value === "boolean") return { kind: "boolean" };
308
+ return ANY;
309
+ }
310
+ function sampleArrayType(value, ancestors) {
311
+ return {
312
+ kind: "array",
313
+ element: value.length > 0 ? fromSample(value[0], ancestors) : ANY
314
+ };
315
+ }
316
+ function sampleObjectType(value, ancestors) {
317
+ const fields = Object.create(null);
318
+ for (const [name, member] of Object.entries(value)) fields[name] = {
319
+ type: fromSample(member, ancestors),
320
+ required: true,
321
+ nullable: member === null
322
+ };
323
+ return {
324
+ kind: "object",
325
+ fields
326
+ };
327
+ }
328
+ function refName(ref) {
329
+ if (typeof ref !== "string") return null;
330
+ for (const prefix of REF_PREFIXES) if (ref.startsWith(prefix)) return decodeURIComponent(ref.slice(prefix.length)).replaceAll("~1", "/").replaceAll("~0", "~");
331
+ return null;
332
+ }
333
+ function primaryType(node) {
334
+ const { type } = node;
335
+ if (typeof type === "string") return type;
336
+ if (Array.isArray(type)) {
337
+ const nonNull = type.find((entry) => typeof entry === "string" && entry !== "null");
338
+ if (typeof nonNull === "string") return nonNull;
339
+ return type.includes("null") ? "null" : null;
340
+ }
341
+ return null;
342
+ }
343
+ function isNullable(node, context) {
344
+ const pending = [node];
345
+ const seenRefs = /* @__PURE__ */ new Set();
346
+ while (pending.length > 0) {
347
+ const current = pending.pop();
348
+ if (!isObject(current)) continue;
349
+ const { type } = current;
350
+ if (type === "null" || Array.isArray(type) && type.includes("null")) return true;
351
+ const ref = refName(current.$ref);
352
+ if (ref !== null && !seenRefs.has(ref) && Object.hasOwn(context.raw, ref)) {
353
+ seenRefs.add(ref);
354
+ pending.push(context.raw[ref]);
355
+ continue;
356
+ }
357
+ const union = current.anyOf ?? current.oneOf;
358
+ if (Array.isArray(union)) pending.push(...union);
359
+ }
360
+ return false;
361
+ }
362
+ function unionBranchKind(branch, context) {
363
+ const seenRefs = /* @__PURE__ */ new Set();
364
+ let current = branch;
365
+ for (let i = 0; i < MAX_DEREF; i++) {
366
+ if (current === false) return "impossible";
367
+ if (!isObject(current)) return "other";
368
+ if (current.type === "null" || Array.isArray(current.type) && current.type.length === 1 && current.type[0] === "null") return "null";
369
+ const ref = refName(current.$ref);
370
+ if (ref === null || seenRefs.has(ref) || !Object.hasOwn(context.raw, ref)) return "other";
371
+ seenRefs.add(ref);
372
+ current = context.raw[ref];
373
+ }
374
+ return "other";
375
+ }
376
+ function description(node) {
377
+ return isObject(node) && typeof node.description === "string" ? node.description : void 0;
378
+ }
379
+ function isObject(value) {
380
+ return typeof value === "object" && value !== null && !Array.isArray(value);
381
+ }
382
+ function asObject(value) {
383
+ return isObject(value) ? value : {};
384
+ }
385
+ function isLiteral(value) {
386
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
387
+ }
388
+ //#endregion
389
+ export { setMinijinjaContext as a, resolveMembers as i, minijinjaContextField as n, typeAtPath as o, normalizeContext as r, elementType as t };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Extension, StateField } from "@codemirror/state";
2
2
  import { EditorView } from "@codemirror/view";
3
3
  import { LanguageSupport } from "@codemirror/language";
4
+ import { DirectMergeConfig, MergeConfig, MergeConfig as MergeConfig$1, MergeView, MergeView as MergeView$1 } from "@codemirror/merge";
4
5
 
5
6
  //#region src/context.d.ts
6
7
  /**
@@ -61,8 +62,9 @@ type EditorContext = {
61
62
  * Reactively set (or clear) the render-context schema an editor completes against. Dispatch this
62
63
  * effect — `setMinijinjaContext.of(normalizeContext(...))`, or `.of(null)` to turn it off — to
63
64
  * update completion without recreating the editor, matching the create-once discipline used
64
- * throughout the toolkit. Defined here (not with the MiniJinja grammar) so it and the field stay
65
- * in the always-loaded chunk, leaving the grammar lazily code-split.
65
+ * throughout the toolkit. Controlled editor hosts install the field before any lazy grammar
66
+ * resolves; a hand-built EditorState should install {@link minijinjaContextField} itself when it
67
+ * needs to dispatch this effect before adding the MiniJinja support.
66
68
  */
67
69
  declare const setMinijinjaContext: import("@codemirror/state").StateEffectType<IrSchema | null>;
68
70
  /**
@@ -84,9 +86,12 @@ declare function normalizeContext(input: EditorContext): IrSchema;
84
86
  */
85
87
  declare function resolveMembers(ir: IrSchema, path: readonly string[], scope?: ReadonlyMap<string, IrType>): Member[];
86
88
  /**
87
- * The type at a declared property path, or null if the path leaves the walkable shape.
89
+ * The type at a declared property path, or null if the path leaves the walkable shape. As in
90
+ * {@link resolveMembers}, the first segment may resolve against a `scope` binding before falling
91
+ * back to the root — so a nested `{% for child in parent.children %}` can walk the `parent` its
92
+ * enclosing loop bound.
88
93
  */
89
- declare function typeAtPath(ir: IrSchema, path: readonly string[]): IrType | null;
94
+ declare function typeAtPath(ir: IrSchema, path: readonly string[], scope?: ReadonlyMap<string, IrType>): IrType | null;
90
95
  //#endregion
91
96
  //#region src/controlled-host.d.ts
92
97
  interface ControlledEditorOptions {
@@ -551,4 +556,130 @@ declare const codeMirrorLanguages: readonly CodeMirrorLanguage[];
551
556
  */
552
557
  declare function loadLanguage(language: CodeMirrorLanguage): Promise<Extension>;
553
558
  //#endregion
554
- export { type CodeMirrorLanguage, ControlledEditorHost, type ControlledEditorOptions, type DocumentExtensionsOptions, type EditorContext, type IrField, type IrSchema, type IrType, type Member, codeMirrorLanguages, documentExtensions, loadLanguage, minijinjaContextField, normalizeContext, resolveMembers, searchPhrasesZhCn, setMinijinjaContext, typeAtPath };
559
+ //#region src/merge-host.d.ts
560
+ interface ControlledMergeOptions extends MergeConfig$1 {
561
+ /**
562
+ * The element the merge view mounts into.
563
+ */
564
+ parent: HTMLElement;
565
+ /**
566
+ * Document or shadow root the merge view is mounted in. Required when `parent` lives outside
567
+ * the global document because the two editors are created before the merge DOM is attached.
568
+ */
569
+ root?: DirectMergeConfig["root"];
570
+ /**
571
+ * Initial text of the original document (editor A — the left side in the default
572
+ * orientation).
573
+ */
574
+ original: string;
575
+ /**
576
+ * Initial text of the modified document (editor B).
577
+ */
578
+ modified: string;
579
+ /**
580
+ * Called with the full original document after every edit made *in* editor A — user
581
+ * typing, or a `revertControls: "b-to-a"` copy. Not called for
582
+ * {@link ControlledMergeHost.setOriginal} reconciliations. Leave editor A read-only
583
+ * (via {@link ControlledMergeOptions.originalExtensions}) when nothing consumes this.
584
+ */
585
+ onOriginalChange?: (value: string) => void;
586
+ /**
587
+ * Called with the full modified document after every edit made *in* editor B — user
588
+ * typing, or a `revertControls: "a-to-b"` copy. Not called for
589
+ * {@link ControlledMergeHost.setModified} reconciliations.
590
+ */
591
+ onModifiedChange?: (value: string) => void;
592
+ /**
593
+ * Static extensions applied to both editors, fixed for the merge view's lifetime.
594
+ */
595
+ extensions?: Extension[];
596
+ /**
597
+ * Static extensions for editor A only — the place to make the original side read-only
598
+ * (`EditorState.readOnly.of(true)`, `EditorView.editable.of(false)`).
599
+ */
600
+ originalExtensions?: Extension[];
601
+ /**
602
+ * Static extensions for editor B only.
603
+ */
604
+ modifiedExtensions?: Extension[];
605
+ /**
606
+ * Extensions that change at runtime (theme, language config …), applied to both editors
607
+ * and swapped in place via {@link ControlledMergeHost.reconfigure}.
608
+ */
609
+ dynamicExtensions?: Extension[];
610
+ }
611
+ /**
612
+ * A controlled CodeMirror 6 merge view — {@link ControlledEditorHost}'s discipline applied to
613
+ * `@codemirror/merge`'s side-by-side {@link MergeView}:
614
+ *
615
+ * - **External value reconcile, per side.** {@link setOriginal} / {@link setModified} diff the
616
+ * incoming value against the live document and dispatch a minimal replace, so a store-driven
617
+ * update never resets the caret and never echoes back through the change callbacks.
618
+ * - **In-place reconfiguration.** `dynamicExtensions` live in one {@link Compartment} per
619
+ * editor; {@link reconfigure} swaps both (light/dark theme, language options) without
620
+ * rebuilding the view. Merge-level options (`orientation`, `revertControls`,
621
+ * `collapseUnchanged` …) reconfigure through {@link MergeView.reconfigure} on {@link view}.
622
+ *
623
+ * Policy-free plumbing: neither side is read-only by default — wire that through
624
+ * `originalExtensions` / `modifiedExtensions` to match how the documents are controlled.
625
+ * Framework-agnostic, same as `ControlledEditorHost`.
626
+ */
627
+ declare class ControlledMergeHost {
628
+ private readonly originalCompartment;
629
+ private readonly modifiedCompartment;
630
+ private silent;
631
+ readonly view: MergeView$1;
632
+ constructor(options: ControlledMergeOptions);
633
+ private reconcile;
634
+ /**
635
+ * Reconcile an externally-changed original value into editor A without resetting its caret.
636
+ * No-op when the document already matches; the write never re-enters `onOriginalChange`.
637
+ */
638
+ setOriginal(value: string): void;
639
+ /**
640
+ * Reconcile an externally-changed modified value into editor B without resetting its caret.
641
+ * No-op when the document already matches; the write never re-enters `onModifiedChange`.
642
+ */
643
+ setModified(value: string): void;
644
+ /**
645
+ * Swap the dynamic extensions (theme, language configuration) in place, on both editors.
646
+ */
647
+ reconfigure(dynamicExtensions: Extension[]): void;
648
+ destroy(): void;
649
+ }
650
+ //#endregion
651
+ //#region src/sql-schema.d.ts
652
+ /**
653
+ * A database's completable surface. Table keys may be schema-qualified (`"analytics.events"`);
654
+ * unqualified keys complete against `defaultSchema` per `@codemirror/lang-sql` semantics.
655
+ */
656
+ interface SqlSchema {
657
+ /**
658
+ * Table name (optionally schema-qualified) → column names, in declaration order.
659
+ */
660
+ readonly tables: Readonly<Record<string, readonly string[]>>;
661
+ /**
662
+ * Table whose columns complete unqualified (no `table.` prefix).
663
+ */
664
+ readonly defaultTable?: string;
665
+ /**
666
+ * Schema whose tables complete unqualified.
667
+ */
668
+ readonly defaultSchema?: string;
669
+ }
670
+ /**
671
+ * Reactively set (or clear) the database schema SQL completion reads. Dispatch
672
+ * `setSqlSchema.of(schema)` — or `.of(null)` to turn table/column completion off — to update a
673
+ * live editor without recreating it, matching the create-once discipline used throughout the
674
+ * toolkit.
675
+ */
676
+ declare const setSqlSchema: import("@codemirror/state").StateEffectType<SqlSchema | null>;
677
+ /**
678
+ * Holds the schema that SQL table/column completion reads. Controlled editor hosts install it
679
+ * eagerly; the `sql`-family language supports also include it for hand-built editors. `null`
680
+ * (the default) means no schema — keyword completion still works, table/column completion stays
681
+ * off.
682
+ */
683
+ declare const sqlSchemaField: StateField<SqlSchema | null>;
684
+ //#endregion
685
+ export { type CodeMirrorLanguage, ControlledEditorHost, type ControlledEditorOptions, ControlledMergeHost, type ControlledMergeOptions, type DocumentExtensionsOptions, type EditorContext, type IrField, type IrSchema, type IrType, type Member, type MergeConfig, type MergeView, type SqlSchema, codeMirrorLanguages, documentExtensions, loadLanguage, minijinjaContextField, normalizeContext, resolveMembers, searchPhrasesZhCn, setMinijinjaContext, setSqlSchema, sqlSchemaField, typeAtPath };