@tiaforge-ui/sinter 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -2
  3. package/dist/cache.d.ts +5 -0
  4. package/dist/cache.js +95 -0
  5. package/dist/check/index.d.ts +82 -0
  6. package/dist/check/index.js +156 -0
  7. package/dist/check/openapi-types.d.ts +6 -0
  8. package/dist/check/openapi-types.js +84 -0
  9. package/dist/check/shadow.d.ts +47 -0
  10. package/dist/check/shadow.js +245 -0
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +67 -0
  13. package/dist/compiler/check-unit.d.ts +97 -0
  14. package/dist/compiler/check-unit.js +3 -0
  15. package/dist/compiler/compile.d.ts +62 -0
  16. package/dist/compiler/compile.js +1133 -0
  17. package/dist/compiler/diagnostic-docs.d.ts +10 -0
  18. package/dist/compiler/diagnostic-docs.js +137 -0
  19. package/dist/compiler/diagnostics.d.ts +56 -0
  20. package/dist/compiler/diagnostics.js +76 -0
  21. package/dist/compiler/expr.d.ts +89 -0
  22. package/dist/compiler/expr.js +838 -0
  23. package/dist/compiler/index.d.ts +21 -0
  24. package/dist/compiler/index.js +106 -0
  25. package/dist/compiler/openapi.d.ts +40 -0
  26. package/dist/compiler/openapi.js +177 -0
  27. package/dist/compiler/vocabulary.d.ts +12 -0
  28. package/dist/compiler/vocabulary.js +38 -0
  29. package/dist/dev.d.ts +10 -0
  30. package/dist/dev.js +92 -0
  31. package/dist/index.d.ts +7 -0
  32. package/dist/index.js +4 -0
  33. package/dist/ir.d.ts +127 -0
  34. package/dist/ir.js +121 -0
  35. package/dist/lsp/quickfix.d.ts +15 -0
  36. package/dist/lsp/quickfix.js +58 -0
  37. package/dist/lsp/rpc.d.ts +31 -0
  38. package/dist/lsp/rpc.js +120 -0
  39. package/dist/lsp/server.d.ts +36 -0
  40. package/dist/lsp/server.js +281 -0
  41. package/dist/lsp/spec-location.d.ts +9 -0
  42. package/dist/lsp/spec-location.js +42 -0
  43. package/dist/lsp/typescript.d.ts +77 -0
  44. package/dist/lsp/typescript.js +242 -0
  45. package/dist/lsp/workspace.d.ts +56 -0
  46. package/dist/lsp/workspace.js +227 -0
  47. package/dist/optimistic.d.ts +3 -0
  48. package/dist/optimistic.js +15 -0
  49. package/dist/paginate.d.ts +3 -0
  50. package/dist/paginate.js +184 -0
  51. package/dist/persist.d.ts +3 -0
  52. package/dist/persist.js +55 -0
  53. package/dist/project.d.ts +36 -0
  54. package/dist/project.js +100 -0
  55. package/dist/runtime/app.d.ts +125 -0
  56. package/dist/runtime/app.js +1183 -0
  57. package/dist/runtime/config.d.ts +53 -0
  58. package/dist/runtime/config.js +118 -0
  59. package/dist/runtime/env.d.ts +18 -0
  60. package/dist/runtime/env.js +23 -0
  61. package/dist/runtime/helpers.d.ts +9 -0
  62. package/dist/runtime/helpers.js +55 -0
  63. package/dist/runtime/request.d.ts +23 -0
  64. package/dist/runtime/request.js +93 -0
  65. package/dist/runtime/security.d.ts +12 -0
  66. package/dist/runtime/security.js +34 -0
  67. package/dist/runtime/store.d.ts +95 -0
  68. package/dist/runtime/store.js +142 -0
  69. package/dist/sse.d.ts +7 -0
  70. package/dist/sse.js +206 -0
  71. package/dist/testing/index.d.ts +71 -0
  72. package/dist/testing/index.js +345 -0
  73. package/dist/vite.d.ts +11 -0
  74. package/dist/vite.js +94 -0
  75. package/dist/ws.d.ts +18 -0
  76. package/dist/ws.js +57 -0
  77. package/docs/diagnostics.md +273 -0
  78. package/html-custom-data.json +234 -0
  79. package/llms.txt +531 -0
  80. package/package.json +90 -4
package/dist/ir.js ADDED
@@ -0,0 +1,121 @@
1
+ // IR v1: the only contract between compiler and runtime. Plain JSON; every
2
+ // expression is an index into the page module's function table.
3
+ export const IR_VERSION = 1;
4
+ /** Structural check of an IR against v1; returns problems (empty when valid). */
5
+ export function validateIR(ir, fnCount) {
6
+ const errs = [];
7
+ const o = ir;
8
+ const fn = (v, at, nullable = true) => {
9
+ if (v === null && nullable)
10
+ return;
11
+ if (v === undefined && nullable)
12
+ return;
13
+ if (!Number.isInteger(v) || v < 0 || v >= fnCount)
14
+ errs.push(`${at}: bad function index ${String(v)}`);
15
+ };
16
+ const url = (u, at) => {
17
+ if (!Array.isArray(u) || !u.length || typeof u[0] !== 'string') {
18
+ errs.push(`${at}: URL template must start with a literal`);
19
+ return;
20
+ }
21
+ u.forEach((p, i) => (typeof p === 'string' ? undefined : fn(p, `${at}[${i}]`, false)));
22
+ };
23
+ if (!o || o.v !== 1)
24
+ return ['v must be 1'];
25
+ for (const k of ['resources', 'syncs', 'roots', 'templates', 'actions'])
26
+ if (!Array.isArray(o[k]))
27
+ errs.push(`${k} must be an array`);
28
+ if (errs.length)
29
+ return errs;
30
+ const names = new Set();
31
+ o.resources.forEach((r, i) => {
32
+ const at = `resources[${i}]`;
33
+ if (!/^[A-Za-z_]\w*$/.test(r.name))
34
+ errs.push(`${at}: bad name`);
35
+ if (names.has(r.name))
36
+ errs.push(`${at}: duplicate name ${r.name}`);
37
+ names.add(r.name);
38
+ url(r.src, `${at}.src`);
39
+ fn(r.when, `${at}.when`);
40
+ if (r.paginate) {
41
+ if (!['keyset', 'offset', 'append'].includes(r.paginate.mode))
42
+ errs.push(`${at}: bad paginate mode`);
43
+ fn(r.paginate.cursor, `${at}.paginate.cursor`);
44
+ }
45
+ });
46
+ o.syncs.forEach((s, i) => {
47
+ url(s.src, `syncs[${i}].src`);
48
+ s.events.forEach((e, j) => {
49
+ const at = `syncs[${i}].events[${j}]`;
50
+ if (!names.has(e.resource))
51
+ errs.push(`${at}: unknown resource ${e.resource}`);
52
+ if (!['upsert', 'patch', 'remove', 'refetch'].includes(e.op))
53
+ errs.push(`${at}: bad op`);
54
+ fn(e.item, `${at}.item`);
55
+ fn(e.set, `${at}.set`);
56
+ fn(e.when, `${at}.when`);
57
+ });
58
+ });
59
+ const kinds = new Set(['text', 'attr', 'class', 'style', 'show', 'model', 'prop', 'for', 'if', 'else', 'on']);
60
+ const nodes = (list, at, inTemplate) => list.forEach((n, i) => {
61
+ const a = `${at}[${i}]`;
62
+ if (inTemplate ? !Array.isArray(n.path) : !Number.isInteger(n.id))
63
+ errs.push(`${a}: needs ${inTemplate ? 'path' : 'id'}`);
64
+ if (n.p >= i)
65
+ errs.push(`${a}: parent scope must come first`);
66
+ if (n.state !== undefined) {
67
+ fn(n.state, `${a}.state`, false);
68
+ if (!Number.isInteger(n.sid))
69
+ errs.push(`${a}: state needs sid`);
70
+ }
71
+ n.b.forEach((b, j) => {
72
+ const ba = `${a}.b[${j}]`;
73
+ if (!kinds.has(b.k))
74
+ errs.push(`${ba}: bad kind ${b.k}`);
75
+ if (b.k === 'on') {
76
+ if (!b.n)
77
+ errs.push(`${ba}: on needs an event name`);
78
+ if (b.x !== undefined) {
79
+ if (!o.actions[b.x])
80
+ errs.push(`${ba}: bad action ${b.x}`);
81
+ }
82
+ else
83
+ fn(b.f, `${ba}.f`, false);
84
+ }
85
+ else if (b.k === 'for' || b.k === 'if' || b.k === 'else') {
86
+ if (!o.templates[b.x])
87
+ errs.push(`${ba}: bad template ${b.x}`);
88
+ if (b.k !== 'else')
89
+ fn(b.f, `${ba}.f`, false);
90
+ if (b.y !== undefined && !list[b.y]?.b.some((x) => x.k === 'else'))
91
+ errs.push(`${ba}: y must point at an else node`);
92
+ }
93
+ else
94
+ fn(b.f, `${ba}.f`, false);
95
+ if (b.k === 'model')
96
+ fn(b.a, `${ba}.a`, false);
97
+ for (const d of [...(b.d ?? []), ...(b.w ?? [])])
98
+ if (typeof d !== 'string' || !d)
99
+ errs.push(`${ba}: bad path`);
100
+ });
101
+ });
102
+ nodes(o.roots, 'roots', false);
103
+ o.templates.forEach((t, i) => {
104
+ if (t.id !== i)
105
+ errs.push(`templates[${i}]: id must equal its index`);
106
+ nodes(t.nodes, `templates[${i}].nodes`, true);
107
+ fn(t.key, `templates[${i}].key`);
108
+ fn(t.find, `templates[${i}].find`);
109
+ });
110
+ o.actions.forEach((a, i) => {
111
+ const at = `actions[${i}]`;
112
+ if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(a.method))
113
+ errs.push(`${at}: bad method`);
114
+ if (!['block', 'queue', 'latest'].includes(a.concurrency))
115
+ errs.push(`${at}: bad concurrency`);
116
+ url(a.url, `${at}.url`);
117
+ for (const k of ['body', 'optimistic', 'then', 'catch'])
118
+ fn(a[k], `${at}.${k}`);
119
+ });
120
+ return errs;
121
+ }
@@ -0,0 +1,15 @@
1
+ import type { Range } from './server.js';
2
+ export interface TextEdit {
3
+ range: Range;
4
+ newText: string;
5
+ }
6
+ /** The suggestion and the misspelled name it replaces, from a diagnostic's message. */
7
+ export declare function suggestionOf(message: string): {
8
+ wrong: string;
9
+ right: string;
10
+ } | null;
11
+ /**
12
+ * The edit for a diagnostic: the occurrence of the misspelled name on the diagnostic's
13
+ * lines that is nearest its start, replaced by the suggestion.
14
+ */
15
+ export declare function didYouMeanEdit(text: string, range: Range, message: string): TextEdit | null;
@@ -0,0 +1,58 @@
1
+ function distance(a, b) {
2
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
3
+ for (let i = 1; i <= a.length; i++) {
4
+ let diag = prev[0];
5
+ prev[0] = i;
6
+ for (let j = 1; j <= b.length; j++) {
7
+ const tmp = prev[j];
8
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
9
+ diag = tmp;
10
+ }
11
+ }
12
+ return prev[b.length];
13
+ }
14
+ /** The suggestion and the misspelled name it replaces, from a diagnostic's message. */
15
+ export function suggestionOf(message) {
16
+ const m = /Did you mean [`']([^`']+)[`']\?/.exec(message);
17
+ if (!m)
18
+ return null;
19
+ const right = m[1];
20
+ const before = message.slice(0, m.index);
21
+ let best = null;
22
+ for (const q of before.matchAll(/`([^`]+)`|'([^']+)'/g)) {
23
+ // `m.inbox_titel`, `$meta.contacts.loadng`: the suggestion replaces the last part
24
+ const whole = q[1] ?? q[2];
25
+ const wrong = /^[\w$-]+$/.test(right) ? whole.split(/[.\s/]/).pop() : whole;
26
+ if (!wrong || wrong === right)
27
+ continue;
28
+ const d = distance(wrong, right);
29
+ if (!best || d < best.d)
30
+ best = { wrong, d };
31
+ }
32
+ return best ? { wrong: best.wrong, right } : null;
33
+ }
34
+ /**
35
+ * The edit for a diagnostic: the occurrence of the misspelled name on the diagnostic's
36
+ * lines that is nearest its start, replaced by the suggestion.
37
+ */
38
+ export function didYouMeanEdit(text, range, message) {
39
+ const s = suggestionOf(message);
40
+ if (!s)
41
+ return null;
42
+ const lines = text.split('\n');
43
+ let best = null;
44
+ for (let l = range.start.line; l <= range.end.line && l < lines.length; l++) {
45
+ const t = lines[l];
46
+ for (let at = t.indexOf(s.wrong); at >= 0; at = t.indexOf(s.wrong, at + 1)) {
47
+ // a whole name, not part of a longer one
48
+ if (/[\w$]/.test(t[at - 1] ?? '') || /[\w$]/.test(t[at + s.wrong.length] ?? ''))
49
+ continue;
50
+ const d = l === range.start.line ? Math.abs(at - range.start.character) : 1e6 + at;
51
+ if (!best || d < best.d)
52
+ best = { line: l, at, d };
53
+ }
54
+ }
55
+ if (!best)
56
+ return null;
57
+ return { range: { start: { line: best.line, character: best.at }, end: { line: best.line, character: best.at + s.wrong.length } }, newText: s.right };
58
+ }
@@ -0,0 +1,31 @@
1
+ import type { Readable, Writable } from 'node:stream';
2
+ type Handler = (params: any) => unknown;
3
+ export declare class RpcError extends Error {
4
+ readonly code: number;
5
+ constructor(code: number, message: string);
6
+ }
7
+ export declare class Connection {
8
+ private output;
9
+ private buf;
10
+ private nextId;
11
+ private pending;
12
+ private requests;
13
+ private notifications;
14
+ private closed;
15
+ /** called for a notification nothing handles */
16
+ onUnhandled?: (method: string, params: unknown) => void;
17
+ onClose?: () => void;
18
+ /** answers a request nothing handles (default: a method-not-found error) */
19
+ requestFallback?: (method: string, params: unknown) => unknown;
20
+ constructor(input: Readable, output: Writable);
21
+ onRequest(method: string, handler: Handler): void;
22
+ onNotification(method: string, handler: Handler): void;
23
+ request<T = any>(method: string, params?: unknown): Promise<T>;
24
+ notify(method: string, params?: unknown): void;
25
+ private send;
26
+ private receive;
27
+ private dispatch;
28
+ private log;
29
+ close(): void;
30
+ }
31
+ export {};
@@ -0,0 +1,120 @@
1
+ export class RpcError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ }
7
+ }
8
+ export class Connection {
9
+ output;
10
+ buf = Buffer.alloc(0);
11
+ nextId = 1;
12
+ pending = new Map();
13
+ requests = new Map();
14
+ notifications = new Map();
15
+ closed = false;
16
+ /** called for a notification nothing handles */
17
+ onUnhandled;
18
+ onClose;
19
+ /** answers a request nothing handles (default: a method-not-found error) */
20
+ requestFallback;
21
+ constructor(input, output) {
22
+ this.output = output;
23
+ input.on('data', (chunk) => this.receive(chunk));
24
+ input.on('end', () => this.close());
25
+ input.on('close', () => this.close());
26
+ }
27
+ onRequest(method, handler) { this.requests.set(method, handler); }
28
+ onNotification(method, handler) { this.notifications.set(method, handler); }
29
+ request(method, params) {
30
+ if (this.closed)
31
+ return Promise.reject(new Error(`connection closed (${method})`));
32
+ const id = this.nextId++;
33
+ return new Promise((resolve, reject) => {
34
+ this.pending.set(id, { resolve, reject });
35
+ this.send({ id, method, params });
36
+ });
37
+ }
38
+ notify(method, params) {
39
+ if (!this.closed)
40
+ this.send({ method, params });
41
+ }
42
+ send(message) {
43
+ const body = JSON.stringify({ jsonrpc: '2.0', ...message });
44
+ this.output.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
45
+ }
46
+ receive(chunk) {
47
+ this.buf = this.buf.length ? Buffer.concat([this.buf, chunk]) : chunk;
48
+ for (;;) {
49
+ const end = this.buf.indexOf('\r\n\r\n');
50
+ if (end < 0)
51
+ return;
52
+ const m = /Content-Length:\s*(\d+)/i.exec(this.buf.subarray(0, end).toString('ascii'));
53
+ if (!m) {
54
+ this.buf = this.buf.subarray(end + 4);
55
+ continue;
56
+ }
57
+ const len = Number(m[1]);
58
+ if (this.buf.length < end + 4 + len)
59
+ return;
60
+ const body = this.buf.subarray(end + 4, end + 4 + len).toString('utf8');
61
+ this.buf = this.buf.subarray(end + 4 + len);
62
+ let msg;
63
+ try {
64
+ msg = JSON.parse(body);
65
+ }
66
+ catch {
67
+ continue;
68
+ }
69
+ this.dispatch(msg);
70
+ }
71
+ }
72
+ dispatch(msg) {
73
+ if (msg.method === undefined) {
74
+ const p = this.pending.get(msg.id);
75
+ if (!p)
76
+ return;
77
+ this.pending.delete(msg.id);
78
+ if (msg.error)
79
+ p.reject(new RpcError(msg.error.code, msg.error.message));
80
+ else
81
+ p.resolve(msg.result ?? null);
82
+ return;
83
+ }
84
+ if (msg.id === undefined) {
85
+ const h = this.notifications.get(msg.method);
86
+ if (h) {
87
+ try {
88
+ void Promise.resolve(h(msg.params)).catch((e) => this.log(msg.method, e));
89
+ }
90
+ catch (e) {
91
+ this.log(msg.method, e);
92
+ }
93
+ }
94
+ else
95
+ this.onUnhandled?.(msg.method, msg.params);
96
+ return;
97
+ }
98
+ const fallback = this.requestFallback;
99
+ const h = this.requests.get(msg.method) ?? (fallback && ((params) => fallback(msg.method, params)));
100
+ if (!h) {
101
+ this.send({ id: msg.id, error: { code: -32601, message: `unhandled method ${msg.method}` } });
102
+ return;
103
+ }
104
+ Promise.resolve()
105
+ .then(() => h(msg.params))
106
+ .then((result) => this.send({ id: msg.id, result: result ?? null }), (e) => this.send({ id: msg.id, error: { code: e instanceof RpcError ? e.code : -32603, message: e instanceof Error ? e.message : String(e) } }));
107
+ }
108
+ log(method, e) {
109
+ process.stderr.write(`sinter lsp: ${method}: ${e instanceof Error ? e.stack ?? e.message : String(e)}\n`);
110
+ }
111
+ close() {
112
+ if (this.closed)
113
+ return;
114
+ this.closed = true;
115
+ for (const p of this.pending.values())
116
+ p.reject(new Error('connection closed'));
117
+ this.pending.clear();
118
+ this.onClose?.();
119
+ }
120
+ }
@@ -0,0 +1,36 @@
1
+ import type { Readable, Writable } from 'node:stream';
2
+ import { type Diagnostic } from '../compiler/index.js';
3
+ import { Connection } from './rpc.js';
4
+ /** How long typing must pause before TypeScript checks the pages. */
5
+ export declare const TYPECHECK_DELAY = 300;
6
+ export interface Position {
7
+ line: number;
8
+ character: number;
9
+ }
10
+ export interface Range {
11
+ start: Position;
12
+ end: Position;
13
+ }
14
+ export interface LspDiagnostic {
15
+ range: Range;
16
+ severity: 1 | 2;
17
+ code: string;
18
+ codeDescription: {
19
+ href: string;
20
+ };
21
+ source: 'sinter';
22
+ message: string;
23
+ }
24
+ /** The range a diagnostic underlines: the name or word at its position, else one character. */
25
+ export declare function rangeOf(text: string, line: number, col: number): Range;
26
+ export declare function toLsp(d: Diagnostic, text: string): LspDiagnostic;
27
+ export interface ServerOptions {
28
+ exit?: (code: number) => void;
29
+ /** milliseconds between the last change and the type check (default TYPECHECK_DELAY) */
30
+ typecheckDelay?: number;
31
+ /** TypeScript's `tsc` script for every project (default: resolved from each project) */
32
+ tsc?: string;
33
+ }
34
+ export declare function startServer(input: Readable, output: Writable, o?: ServerOptions): Connection;
35
+ /** The `src` of an `<sn-include>` when the 0-based position is inside its value, else null. */
36
+ export declare function includeAt(text: string, line: number, character: number): string | null;
@@ -0,0 +1,281 @@
1
+ import { fileURLToPath, pathToFileURL } from 'node:url';
2
+ import { CODES, docsUrl } from '../compiler/index.js';
3
+ import { check, findTsc } from '../check/index.js';
4
+ import { Connection } from './rpc.js';
5
+ import { existsSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { didYouMeanEdit } from './quickfix.js';
8
+ import { TypeScriptBridge, typescriptMajor } from './typescript.js';
9
+ import { Workspace } from './workspace.js';
10
+ /** How long typing must pause before TypeScript checks the pages. */
11
+ export const TYPECHECK_DELAY = 300;
12
+ /** The range a diagnostic underlines: the name or word at its position, else one character. */
13
+ export function rangeOf(text, line, col) {
14
+ const lines = text.split('\n');
15
+ const l = Math.max(0, Math.min(line - 1, lines.length - 1));
16
+ const s = lines[l] ?? '';
17
+ const c = Math.max(0, Math.min(col - 1, s.length));
18
+ const m = /^[\w$-]+/.exec(s.slice(c));
19
+ const end = m ? c + m[0].length : Math.min(c + 1, s.length);
20
+ return { start: { line: l, character: c }, end: { line: l, character: end > c ? end : c } };
21
+ }
22
+ export function toLsp(d, text) {
23
+ const name = CODES[d.code];
24
+ return {
25
+ range: d.span
26
+ ? { start: { line: d.span.line - 1, character: d.span.col - 1 }, end: { line: d.span.endLine - 1, character: d.span.endCol - 1 } }
27
+ : rangeOf(text, d.line, d.col),
28
+ severity: d.severity === 'error' ? 1 : 2,
29
+ code: name ? `${d.code} ${name}` : d.code,
30
+ codeDescription: { href: docsUrl(d.code) },
31
+ source: 'sinter',
32
+ message: d.message + (d.hint ? `\nhint: ${d.hint}` : ''),
33
+ };
34
+ }
35
+ const uriToPath = (uri) => fileURLToPath(uri);
36
+ const pathToUri = (path) => pathToFileURL(path).href;
37
+ export function startServer(input, output, o = {}) {
38
+ const conn = new Connection(input, output);
39
+ const last = new Map();
40
+ /** per project root: the TypeScript bridge, 'cli' for TypeScript before 7 (checked on save), or 'none' */
41
+ const typescript = new Map();
42
+ const timers = new Map();
43
+ const delay = o.typecheckDelay ?? TYPECHECK_DELAY;
44
+ let disposed = false;
45
+ const modeOf = (root) => {
46
+ let m = typescript.get(root);
47
+ if (m)
48
+ return m;
49
+ try {
50
+ const tsc = o.tsc ?? findTsc(root);
51
+ m = typescriptMajor(tsc) >= 7 ? new TypeScriptBridge(root, tsc) : 'cli';
52
+ }
53
+ catch {
54
+ m = 'none';
55
+ }
56
+ typescript.set(root, m);
57
+ return m;
58
+ };
59
+ const typecheck = async (state) => {
60
+ const mode = modeOf(state.root);
61
+ if (mode === 'none')
62
+ return;
63
+ if (mode === 'cli') {
64
+ const p = state.project;
65
+ const r = check({ root: state.root, units: [...state.units.values()], helpers: p.helpers, types: p.types, openapi: p.openapi, tsc: o.tsc ?? findTsc(state.root) });
66
+ await workspace.setTypes(state, '*', r.diagnostics);
67
+ return;
68
+ }
69
+ await mode.update(state.project, state.units);
70
+ for (const page of state.units.keys())
71
+ await workspace.setTypes(state, page, await mode.diagnostics(page));
72
+ };
73
+ const schedule = (state, wait) => {
74
+ clearTimeout(timers.get(state.root));
75
+ timers.set(state.root, setTimeout(() => {
76
+ timers.delete(state.root);
77
+ typecheck(state).catch((e) => disposed || process.stderr.write(`sinter lsp: type check failed: ${e instanceof Error ? e.stack : String(e)}\n`));
78
+ }, wait));
79
+ };
80
+ const workspace = new Workspace({
81
+ compiled(state) {
82
+ // before TypeScript 7 the check runs `tsc` once per save instead
83
+ if (modeOf(state.root) !== 'cli')
84
+ schedule(state, delay);
85
+ },
86
+ publish(file, diagnostics) {
87
+ let text = '';
88
+ try {
89
+ text = workspace.read(file);
90
+ }
91
+ catch { /* deleted: ranges fall back to line starts */ }
92
+ const params = { uri: pathToUri(file), diagnostics: diagnostics.map((d) => toLsp(d, text)) };
93
+ const json = JSON.stringify(params.diagnostics);
94
+ if (last.get(file) === json)
95
+ return;
96
+ last.set(file, json);
97
+ conn.notify('textDocument/publishDiagnostics', params);
98
+ },
99
+ });
100
+ let watchDynamic = false;
101
+ let shutdown = false;
102
+ conn.onRequest('initialize', (params) => {
103
+ watchDynamic = !!params?.capabilities?.workspace?.didChangeWatchedFiles?.dynamicRegistration;
104
+ const folders = (params?.workspaceFolders ?? []).map((f) => uriToPath(f.uri));
105
+ if (!folders.length && params?.rootUri)
106
+ folders.push(uriToPath(params.rootUri));
107
+ // load the projects of the open folders early, so the first open file is quick
108
+ for (const f of folders)
109
+ void workspace.projectFor(f + '/index.html').catch(() => { });
110
+ return {
111
+ capabilities: {
112
+ // full text on every change
113
+ textDocumentSync: { openClose: true, change: 1, save: { includeText: false } },
114
+ hoverProvider: true,
115
+ completionProvider: { triggerCharacters: ['.'], resolveProvider: true },
116
+ definitionProvider: true,
117
+ codeActionProvider: { codeActionKinds: ['quickfix'] },
118
+ },
119
+ serverInfo: { name: 'sinter' },
120
+ };
121
+ });
122
+ conn.onNotification('initialized', () => {
123
+ if (!watchDynamic)
124
+ return;
125
+ void conn.request('client/registerCapability', {
126
+ registrations: [{
127
+ id: 'sinter-watch',
128
+ method: 'workspace/didChangeWatchedFiles',
129
+ registerOptions: {
130
+ watchers: [
131
+ { globPattern: '**/*.html' },
132
+ { globPattern: '**/vite.config.{js,ts,mjs,mts,cjs,cts}' },
133
+ { globPattern: '**/src/sinter.{helpers,types,config}.{js,ts}' },
134
+ { globPattern: '**/src/sinter.types.d.ts' },
135
+ { globPattern: '**/*.{yml,yaml,json}' },
136
+ { globPattern: '**/src/**/*.{js,ts}' },
137
+ ],
138
+ },
139
+ }],
140
+ }).catch(() => { });
141
+ });
142
+ const local = (uri) => uri.startsWith('file:');
143
+ conn.onNotification('textDocument/didOpen', (p) => {
144
+ if (local(p.textDocument.uri))
145
+ return workspace.open(uriToPath(p.textDocument.uri), p.textDocument.text);
146
+ });
147
+ conn.onNotification('textDocument/didChange', (p) => {
148
+ const text = p.contentChanges[p.contentChanges.length - 1]?.text;
149
+ if (local(p.textDocument.uri) && typeof text === 'string')
150
+ return workspace.change(uriToPath(p.textDocument.uri), text);
151
+ });
152
+ conn.onNotification('textDocument/didClose', (p) => {
153
+ if (local(p.textDocument.uri))
154
+ return workspace.close(uriToPath(p.textDocument.uri));
155
+ });
156
+ conn.onNotification('textDocument/didSave', async (p) => {
157
+ if (!local(p.textDocument.uri))
158
+ return;
159
+ const file = uriToPath(p.textDocument.uri);
160
+ await workspace.fileChanged(file);
161
+ const state = await workspace.projectFor(file);
162
+ if (modeOf(state.root) === 'cli')
163
+ schedule(state, 0);
164
+ });
165
+ conn.onNotification('workspace/didChangeWatchedFiles', async (p) => {
166
+ const files = (p.changes ?? []).filter((c) => local(c.uri)).map((c) => uriToPath(c.uri));
167
+ for (const m of typescript.values())
168
+ if (m instanceof TypeScriptBridge)
169
+ m.filesChanged(files.filter((f) => !f.endsWith('.html')));
170
+ for (const f of files)
171
+ await workspace.fileChanged(f);
172
+ });
173
+ conn.onRequest('textDocument/hover', async (p) => {
174
+ if (!local(p.textDocument.uri))
175
+ return null;
176
+ const file = uriToPath(p.textDocument.uri);
177
+ const state = await workspace.projectFor(file);
178
+ const mode = modeOf(state.root);
179
+ if (!(mode instanceof TypeScriptBridge))
180
+ return null;
181
+ // the shadows must reflect the latest edit
182
+ await mode.update(state.project, state.units);
183
+ return mode.hover(file, p.position.line + 1, p.position.character + 1);
184
+ });
185
+ /** the bridge for a file's project, with its shadows brought up to date */
186
+ const bridgeFor = async (file) => {
187
+ const state = await workspace.projectFor(file);
188
+ const mode = modeOf(state.root);
189
+ if (!(mode instanceof TypeScriptBridge))
190
+ return null;
191
+ await mode.update(state.project, state.units);
192
+ return { bridge: mode, state };
193
+ };
194
+ conn.onRequest('textDocument/completion', async (p) => {
195
+ if (!local(p.textDocument.uri))
196
+ return null;
197
+ const file = uriToPath(p.textDocument.uri);
198
+ const b = await bridgeFor(file);
199
+ return b ? b.bridge.completion(file, p.position.line + 1, p.position.character + 1, p.context) : null;
200
+ });
201
+ conn.onRequest('completionItem/resolve', async (item) => {
202
+ const m = item?.data?.sinter ? typescript.get(item.data.sinter) : null;
203
+ return m instanceof TypeScriptBridge ? m.resolve(item) : item;
204
+ });
205
+ const location = (t) => {
206
+ let text = '';
207
+ try {
208
+ text = workspace.read(t.file);
209
+ }
210
+ catch { /* the target's range falls back to its start */ }
211
+ const range = t.endLine !== undefined && t.endCol !== undefined
212
+ ? { start: { line: t.line - 1, character: t.col - 1 }, end: { line: t.endLine - 1, character: t.endCol - 1 } }
213
+ : rangeOf(text, t.line, t.col);
214
+ return { uri: pathToUri(t.file), range };
215
+ };
216
+ conn.onRequest('textDocument/definition', async (p) => {
217
+ if (!local(p.textDocument.uri))
218
+ return null;
219
+ const file = uriToPath(p.textDocument.uri);
220
+ const partial = includeAt(workspace.read(file), p.position.line, p.position.character);
221
+ if (partial !== null) {
222
+ const target = resolve(dirname(file), partial);
223
+ return existsSync(target) ? [{ uri: pathToUri(target), range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }] : null;
224
+ }
225
+ const b = await bridgeFor(file);
226
+ if (!b)
227
+ return null;
228
+ const targets = await b.bridge.definition(file, p.position.line + 1, p.position.character + 1, b.state.project);
229
+ return targets.map(location);
230
+ });
231
+ conn.onRequest('textDocument/codeAction', (p) => {
232
+ if (!local(p.textDocument.uri))
233
+ return [];
234
+ let text;
235
+ try {
236
+ text = workspace.read(uriToPath(p.textDocument.uri));
237
+ }
238
+ catch {
239
+ return [];
240
+ }
241
+ const actions = [];
242
+ for (const d of p.context?.diagnostics ?? []) {
243
+ if (d.source !== 'sinter')
244
+ continue;
245
+ const edit = didYouMeanEdit(text, d.range, d.message);
246
+ if (!edit)
247
+ continue;
248
+ actions.push({
249
+ title: `Change to '${edit.newText}'`, kind: 'quickfix', isPreferred: true, diagnostics: [d],
250
+ edit: { changes: { [p.textDocument.uri]: [edit] } },
251
+ });
252
+ }
253
+ return actions;
254
+ });
255
+ const dispose = () => {
256
+ disposed = true;
257
+ for (const t of timers.values())
258
+ clearTimeout(t);
259
+ timers.clear();
260
+ for (const m of typescript.values())
261
+ if (m instanceof TypeScriptBridge)
262
+ m.dispose();
263
+ typescript.clear();
264
+ };
265
+ conn.onRequest('shutdown', () => { shutdown = true; dispose(); return null; });
266
+ const closed = conn.onClose;
267
+ conn.onClose = () => { dispose(); closed?.(); };
268
+ conn.onNotification('exit', () => { conn.close(); o.exit?.(shutdown ? 0 : 1); });
269
+ conn.onUnhandled = () => { };
270
+ return conn;
271
+ }
272
+ /** The `src` of an `<sn-include>` when the 0-based position is inside its value, else null. */
273
+ export function includeAt(text, line, character) {
274
+ const t = text.split('\n')[line] ?? '';
275
+ for (const m of t.matchAll(/<sn-include\b[^>]*?\bsrc\s*=\s*(["'])([^"']*)\1/g)) {
276
+ const start = m.index + m[0].length - 1 - m[2].length;
277
+ if (character >= start && character <= start + m[2].length)
278
+ return m[2];
279
+ }
280
+ return null;
281
+ }
@@ -0,0 +1,9 @@
1
+ export interface SpecLocation {
2
+ line: number;
3
+ col: number;
4
+ }
5
+ /**
6
+ * The position of `components.schemas.<schema>` in `file`, or of its `properties.<prop>` when
7
+ * given and present. `schema` is the TypeScript name; the spec's own name is looked up.
8
+ */
9
+ export declare function specLocation(file: string, schema: string, prop?: string): SpecLocation | null;