@lucidkiln/language-server 0.0.0-stage → 1.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.
- package/LICENSE +21 -0
- package/README.md +11 -2
- package/dist/main.js +2561 -0
- package/dist/main.js.map +1 -0
- package/package.json +48 -5
package/dist/main.js
ADDED
|
@@ -0,0 +1,2561 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { DiagnosticSeverity, DidChangeWatchedFilesNotification, FileChangeType, ProposedFeatures, TextDocumentSyncKind, TextDocuments, createConnection } from "vscode-languageserver/node";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
|
+
import { TextDocument } from "vscode-languageserver-textdocument";
|
|
7
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
8
|
+
import { CompletionItemKind, InsertTextFormat, InsertTextMode, MarkupKind, SymbolKind } from "vscode-languageserver";
|
|
9
|
+
import * as bundledKiln from "@lucidkiln/core";
|
|
10
|
+
import { templateText } from "@lucidkiln/core";
|
|
11
|
+
import { getLanguageService } from "vscode-html-languageservice";
|
|
12
|
+
import * as bundledFormat from "@lucidkiln/core/format";
|
|
13
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
14
|
+
import { parse } from "@lucidkiln/core/syntax";
|
|
15
|
+
//#region src/builtins.ts
|
|
16
|
+
const block = (name, header) => `${name}${header} %}\n\t$0\n{% end${name} %}`;
|
|
17
|
+
const TAGS = {
|
|
18
|
+
if: { snippet: block("if", " ${1:condition}") },
|
|
19
|
+
elseif: { snippet: "elseif ${1:condition} %}" },
|
|
20
|
+
else: { snippet: "else %}" },
|
|
21
|
+
for: { snippet: block("for", " ${1:item} in ${2:items}") },
|
|
22
|
+
set: { snippet: "set ${1:name} = ${2:value} %}" },
|
|
23
|
+
props: { snippet: "props ${1:name} %}" },
|
|
24
|
+
extends: { snippet: "extends \"${1:layouts/base}\" %}" },
|
|
25
|
+
block: { snippet: block("block", " ${1:name}") },
|
|
26
|
+
include: { snippet: "include \"${1}\" %}" },
|
|
27
|
+
component: { snippet: "component \"${1}\" with { $2 } %}\n $0\n{% endcomponent %}" },
|
|
28
|
+
island: { snippet: "island \"${1}\" with { $2 } %}\n $0\n{% endisland %}" },
|
|
29
|
+
fill: { snippet: block("fill", " \"${1:name}\"") },
|
|
30
|
+
slot: { snippet: "slot %}$0{% endslot %}" },
|
|
31
|
+
macro: { snippet: block("macro", " ${1:name}(${2})") },
|
|
32
|
+
import: { snippet: "import \"${1}\" as ${2:name} %}" },
|
|
33
|
+
from: { snippet: "from \"${1}\" import ${2:name} %}" },
|
|
34
|
+
with: { snippet: block("with", " { ${1} }") },
|
|
35
|
+
cache: { snippet: block("cache", " \"${1:key}\" for ${2:600}") },
|
|
36
|
+
outlet: { snippet: "outlet \"${1:head}\" %}" },
|
|
37
|
+
add: { snippet: block("add", " to \"${1:head}\"") },
|
|
38
|
+
flush: { snippet: "flush %}" },
|
|
39
|
+
apply: { snippet: block("apply", " ${1:filter}") },
|
|
40
|
+
autoescape: { snippet: block("autoescape", " \"${1:html}\"") },
|
|
41
|
+
verbatim: { snippet: "verbatim %}\n$0\n{% endverbatim %}" }
|
|
42
|
+
};
|
|
43
|
+
/** Tags that have a body and an end tag. */
|
|
44
|
+
const BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
45
|
+
"if",
|
|
46
|
+
"for",
|
|
47
|
+
"set",
|
|
48
|
+
"block",
|
|
49
|
+
"component",
|
|
50
|
+
"island",
|
|
51
|
+
"fill",
|
|
52
|
+
"slot",
|
|
53
|
+
"macro",
|
|
54
|
+
"with",
|
|
55
|
+
"cache",
|
|
56
|
+
"add",
|
|
57
|
+
"apply",
|
|
58
|
+
"autoescape",
|
|
59
|
+
"verbatim"
|
|
60
|
+
]);
|
|
61
|
+
const FILTERS = {
|
|
62
|
+
upper: { signature: "upper" },
|
|
63
|
+
lower: { signature: "lower" },
|
|
64
|
+
capitalize: { signature: "capitalize" },
|
|
65
|
+
title: { signature: "title" },
|
|
66
|
+
trim: { signature: "trim(characters?, side?)" },
|
|
67
|
+
replace: { signature: "replace(search, replacement)" },
|
|
68
|
+
split: { signature: "split(separator, limit?)" },
|
|
69
|
+
truncate: { signature: "truncate(length, ellipsis?)" },
|
|
70
|
+
stripTags: { signature: "stripTags" },
|
|
71
|
+
nl2br: { signature: "nl2br" },
|
|
72
|
+
urlEncode: { signature: "urlEncode" },
|
|
73
|
+
json: { signature: "json(indent?)" },
|
|
74
|
+
escape: { signature: "escape(strategy?)" },
|
|
75
|
+
raw: { signature: "raw" },
|
|
76
|
+
default: { signature: "default(fallback)" },
|
|
77
|
+
length: { signature: "length" },
|
|
78
|
+
first: { signature: "first" },
|
|
79
|
+
last: { signature: "last" },
|
|
80
|
+
slice: { signature: "slice(start, end?)" },
|
|
81
|
+
join: { signature: "join(glue?, lastGlue?)" },
|
|
82
|
+
keys: { signature: "keys" },
|
|
83
|
+
merge: { signature: "merge(...values)" },
|
|
84
|
+
reverse: { signature: "reverse" },
|
|
85
|
+
sort: { signature: "sort(by?)" },
|
|
86
|
+
map: { signature: "map((value, key) => ...)" },
|
|
87
|
+
filter: { signature: "filter((value, key) => ...)" },
|
|
88
|
+
reduce: { signature: "reduce((total, value, key) => ..., initial)" },
|
|
89
|
+
find: { signature: "find((value, key) => ...)" },
|
|
90
|
+
column: { signature: "column(name)" },
|
|
91
|
+
batch: { signature: "batch(size, fill?)" },
|
|
92
|
+
abs: { signature: "abs" },
|
|
93
|
+
round: { signature: "round(precision?, method?)" },
|
|
94
|
+
formatNumber: { signature: "formatNumber(...options)" },
|
|
95
|
+
plural: { signature: "plural(one, other, ...forms)" },
|
|
96
|
+
formatDate: { signature: "formatDate(...options)" }
|
|
97
|
+
};
|
|
98
|
+
const FUNCTIONS = {
|
|
99
|
+
range: { signature: "range(start, end, step?)" },
|
|
100
|
+
max: { signature: "max(...values)" },
|
|
101
|
+
min: { signature: "min(...values)" },
|
|
102
|
+
cycle: { signature: "cycle(values, position)" },
|
|
103
|
+
date: { signature: "date(value?)" },
|
|
104
|
+
attrs: { signature: "attrs(attributes)" },
|
|
105
|
+
dump: { signature: "dump(...values)" },
|
|
106
|
+
parent: { signature: "parent()" },
|
|
107
|
+
block: { signature: "block(name)" }
|
|
108
|
+
};
|
|
109
|
+
const TESTS = {
|
|
110
|
+
defined: { signature: "defined" },
|
|
111
|
+
null: { signature: "null" },
|
|
112
|
+
empty: { signature: "empty" },
|
|
113
|
+
even: { signature: "even" },
|
|
114
|
+
odd: { signature: "odd" },
|
|
115
|
+
iterable: { signature: "iterable" },
|
|
116
|
+
"divisible by": { signature: "divisible by(divisor)" }
|
|
117
|
+
};
|
|
118
|
+
const LOOP_PROPERTIES = {
|
|
119
|
+
index: "Position, starting at 1.",
|
|
120
|
+
index0: "Position, starting at 0.",
|
|
121
|
+
revIndex: "Items left, ending at 1.",
|
|
122
|
+
revIndex0: "Items left, ending at 0.",
|
|
123
|
+
first: "`true` on the first item.",
|
|
124
|
+
last: "`true` on the last item.",
|
|
125
|
+
length: "Total number of items."
|
|
126
|
+
};
|
|
127
|
+
const KEYWORDS$1 = [
|
|
128
|
+
"true",
|
|
129
|
+
"false",
|
|
130
|
+
"null",
|
|
131
|
+
"and",
|
|
132
|
+
"or",
|
|
133
|
+
"not",
|
|
134
|
+
"in",
|
|
135
|
+
"is"
|
|
136
|
+
];
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/context.ts
|
|
139
|
+
/** Where the output or tag around `offset` opens, and its kind. Null outside one, or inside a comment. */
|
|
140
|
+
const enclosingDelimiter = (text, offset) => {
|
|
141
|
+
const before = text.slice(0, offset);
|
|
142
|
+
const output = before.lastIndexOf("{{");
|
|
143
|
+
const tag = before.lastIndexOf("{%");
|
|
144
|
+
const comment = before.lastIndexOf("{#");
|
|
145
|
+
if (comment !== -1 && before.indexOf("#}", comment) === -1) return null;
|
|
146
|
+
const start = Math.max(output, tag);
|
|
147
|
+
if (start === -1 || start < comment) return null;
|
|
148
|
+
const kind = start === tag ? "tag" : "output";
|
|
149
|
+
if (before.indexOf(kind === "tag" ? "%}" : "}}", start) !== -1) return null;
|
|
150
|
+
return {
|
|
151
|
+
start,
|
|
152
|
+
kind
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
/** The block tags open at `offset`, innermost last, from the tags before it. */
|
|
156
|
+
const openBlocks = (text, offset) => {
|
|
157
|
+
const stack = [];
|
|
158
|
+
for (const match of text.slice(0, offset).matchAll(/\{%-?\s*(\w+)([^%]*)%\}/g)) {
|
|
159
|
+
const name = match[1];
|
|
160
|
+
const rest = match[2];
|
|
161
|
+
if (BLOCK_TAGS.has(name)) {
|
|
162
|
+
if (name === "set" && rest.includes("=")) continue;
|
|
163
|
+
if (name === "block" && rest.trim().includes(" ")) continue;
|
|
164
|
+
stack.push(name);
|
|
165
|
+
} else if (name.startsWith("end")) {
|
|
166
|
+
const index = stack.lastIndexOf(name.slice(3));
|
|
167
|
+
if (index !== -1) stack.length = index;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return stack;
|
|
171
|
+
};
|
|
172
|
+
const WORD_END = /[A-Za-z_][A-Za-z0-9_]*$/;
|
|
173
|
+
const cursorContext = (text, offset) => {
|
|
174
|
+
const delimiter = enclosingDelimiter(text, offset);
|
|
175
|
+
if (!delimiter) return null;
|
|
176
|
+
const inner = text.slice(delimiter.start + 2, offset).replace(/^-/, "");
|
|
177
|
+
const word = WORD_END.exec(inner)?.[0] ?? "";
|
|
178
|
+
const start = offset - word.length;
|
|
179
|
+
const head = inner.slice(0, inner.length - word.length);
|
|
180
|
+
if (delimiter.kind === "tag" && /^\s*$/.test(head)) {
|
|
181
|
+
const after = /^\w*(\s*-?%\})/.exec(text.slice(offset).split("\n")[0] ?? "");
|
|
182
|
+
const blocks = openBlocks(text, delimiter.start);
|
|
183
|
+
return {
|
|
184
|
+
kind: "tag",
|
|
185
|
+
start,
|
|
186
|
+
end: after ? offset + after[0].length : null,
|
|
187
|
+
open: blocks.at(-1) ?? null
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const template = /\b(?:extends|include|component|island|import|from)\s+(["'])([^"']*)$/.exec(inner);
|
|
191
|
+
if (template) {
|
|
192
|
+
const prefix = template[2];
|
|
193
|
+
return {
|
|
194
|
+
kind: "template",
|
|
195
|
+
start: offset - prefix.length,
|
|
196
|
+
prefix
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (/\bloop\.$/.test(head)) return {
|
|
200
|
+
kind: "loop",
|
|
201
|
+
start
|
|
202
|
+
};
|
|
203
|
+
if (/(?:^|[^|])\|\s*$/.test(head)) return {
|
|
204
|
+
kind: "filter",
|
|
205
|
+
start
|
|
206
|
+
};
|
|
207
|
+
if (/\bis\s+(?:not\s+)?$/.test(head)) return {
|
|
208
|
+
kind: "test",
|
|
209
|
+
start
|
|
210
|
+
};
|
|
211
|
+
const props = /\b(?:include|component|island)\s+(["'])([^"']+)\1\s+with\s+\{([^{}]*)$/.exec(head);
|
|
212
|
+
if (props && /(?:^|,)\s*$/.test(props[3])) return {
|
|
213
|
+
kind: "prop",
|
|
214
|
+
start,
|
|
215
|
+
template: props[2]
|
|
216
|
+
};
|
|
217
|
+
if (/[\w\])]\??\.$/.test(head) && !/(?:^|[^\w$])\d+\.$/.test(head)) return {
|
|
218
|
+
kind: "member",
|
|
219
|
+
start
|
|
220
|
+
};
|
|
221
|
+
if (/[.\w]$/.test(head) || /["']$/.test(head)) return null;
|
|
222
|
+
return {
|
|
223
|
+
kind: "expression",
|
|
224
|
+
start
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
/** Every template name written as a string after `extends`, `include`, `component`, `island`, `import` or `from`, with the name's offsets. */
|
|
228
|
+
const templateStrings = (text) => {
|
|
229
|
+
const found = [];
|
|
230
|
+
for (const match of text.matchAll(/\{%-?\s*(?:extends|include|component|island|import|from)\s+(["'])([^"'\n]*)\1/g)) {
|
|
231
|
+
const name = match[2];
|
|
232
|
+
const end = match.index + match[0].length - 1;
|
|
233
|
+
found.push({
|
|
234
|
+
name,
|
|
235
|
+
start: end - name.length,
|
|
236
|
+
end
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return found;
|
|
240
|
+
};
|
|
241
|
+
const symbolAt = (text, offset) => {
|
|
242
|
+
const string = templateStrings(text).find((found) => offset >= found.start && offset <= found.end);
|
|
243
|
+
if (string) return {
|
|
244
|
+
kind: "template",
|
|
245
|
+
...string
|
|
246
|
+
};
|
|
247
|
+
const delimiter = enclosingDelimiter(text, offset);
|
|
248
|
+
if (!delimiter) return null;
|
|
249
|
+
let start = offset;
|
|
250
|
+
let end = offset;
|
|
251
|
+
while (start > 0 && /\w/.test(text[start - 1])) start--;
|
|
252
|
+
while (end < text.length && /\w/.test(text[end])) end++;
|
|
253
|
+
if (start === end || !/^[A-Za-z_]/.test(text.slice(start))) return null;
|
|
254
|
+
let name = text.slice(start, end);
|
|
255
|
+
const head = text.slice(delimiter.start + 2, start);
|
|
256
|
+
const tail = text.slice(end, end + 40);
|
|
257
|
+
if (delimiter.kind === "tag" && /^-?\s*$/.test(head)) return {
|
|
258
|
+
kind: "tag",
|
|
259
|
+
name,
|
|
260
|
+
start,
|
|
261
|
+
end
|
|
262
|
+
};
|
|
263
|
+
if (/\|\s*$/.test(head)) return {
|
|
264
|
+
kind: "filter",
|
|
265
|
+
name,
|
|
266
|
+
start,
|
|
267
|
+
end
|
|
268
|
+
};
|
|
269
|
+
if (/\bis\s+(?:not\s+)?$/.test(head)) {
|
|
270
|
+
if (name === "divisible" && /^\s+by\b/.test(tail)) name = "divisible by";
|
|
271
|
+
return {
|
|
272
|
+
kind: "test",
|
|
273
|
+
name,
|
|
274
|
+
start,
|
|
275
|
+
end
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (name === "by" && /\bis\s+(?:not\s+)?divisible\s+$/.test(head)) return {
|
|
279
|
+
kind: "test",
|
|
280
|
+
name: "divisible by",
|
|
281
|
+
start,
|
|
282
|
+
end
|
|
283
|
+
};
|
|
284
|
+
if (/\bloop\.$/.test(head)) return {
|
|
285
|
+
kind: "loop",
|
|
286
|
+
name,
|
|
287
|
+
start,
|
|
288
|
+
end
|
|
289
|
+
};
|
|
290
|
+
if (/[.\w]$/.test(head)) return null;
|
|
291
|
+
if (/^\s*\(/.test(tail)) return {
|
|
292
|
+
kind: "function",
|
|
293
|
+
name,
|
|
294
|
+
start,
|
|
295
|
+
end
|
|
296
|
+
};
|
|
297
|
+
return {
|
|
298
|
+
kind: "variable",
|
|
299
|
+
name,
|
|
300
|
+
start,
|
|
301
|
+
end
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region src/calls.ts
|
|
306
|
+
const CLOSERS = {
|
|
307
|
+
"(": ")",
|
|
308
|
+
"[": "]",
|
|
309
|
+
"{": "}"
|
|
310
|
+
};
|
|
311
|
+
/** Words before `(` that aren't calls, as in `not (a or b)`. */
|
|
312
|
+
const KEYWORDS = /* @__PURE__ */ new Set([
|
|
313
|
+
"and",
|
|
314
|
+
"or",
|
|
315
|
+
"not",
|
|
316
|
+
"in",
|
|
317
|
+
"is",
|
|
318
|
+
"if",
|
|
319
|
+
"else"
|
|
320
|
+
]);
|
|
321
|
+
const NAMED = /^\s*([A-Za-z_]\w*)\s*:(?!:)/;
|
|
322
|
+
const callContext = (text, offset) => {
|
|
323
|
+
const delimiter = enclosingDelimiter(text, offset);
|
|
324
|
+
if (!delimiter) return null;
|
|
325
|
+
const stack = [];
|
|
326
|
+
let quote = null;
|
|
327
|
+
for (let index = delimiter.start + 2; index < offset; index++) {
|
|
328
|
+
const char = text[index];
|
|
329
|
+
if (quote) {
|
|
330
|
+
if (char === "\\") index++;
|
|
331
|
+
else if (char === quote) quote = null;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (char === "\"" || char === "'") quote = char;
|
|
335
|
+
else if (char in CLOSERS) {
|
|
336
|
+
let callee = null;
|
|
337
|
+
if (char === "(") {
|
|
338
|
+
const match = /(\|\s*|\.\s*)?([A-Za-z_]\w*)\s*$/.exec(text.slice(delimiter.start + 2, index));
|
|
339
|
+
const name = match?.[2];
|
|
340
|
+
if (name && !KEYWORDS.has(name) && !match[1]?.startsWith(".")) callee = {
|
|
341
|
+
kind: match[1] ? "filter" : "function",
|
|
342
|
+
name
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
stack.push({
|
|
346
|
+
char,
|
|
347
|
+
callee,
|
|
348
|
+
commas: 0,
|
|
349
|
+
segment: index + 1,
|
|
350
|
+
sawNamed: false
|
|
351
|
+
});
|
|
352
|
+
} else if (char === ")" || char === "]" || char === "}") stack.pop();
|
|
353
|
+
else if (char === ",") {
|
|
354
|
+
const frame = stack.at(-1);
|
|
355
|
+
if (frame) {
|
|
356
|
+
if (NAMED.test(text.slice(frame.segment, index))) frame.sawNamed = true;
|
|
357
|
+
frame.commas++;
|
|
358
|
+
frame.segment = index + 1;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const frame = stack.at(-1);
|
|
363
|
+
if (frame?.char !== "(" || !frame.callee) return null;
|
|
364
|
+
const segment = text.slice(frame.segment, offset);
|
|
365
|
+
const word = /[A-Za-z_]\w*$/.exec(segment)?.[0] ?? "";
|
|
366
|
+
return {
|
|
367
|
+
...frame.callee,
|
|
368
|
+
index: frame.commas,
|
|
369
|
+
named: NAMED.exec(segment)?.[1] ?? null,
|
|
370
|
+
sawNamed: frame.sawNamed,
|
|
371
|
+
namePosition: /^\s*[A-Za-z_]?\w*$/.test(segment),
|
|
372
|
+
start: offset - word.length
|
|
373
|
+
};
|
|
374
|
+
};
|
|
375
|
+
/**
|
|
376
|
+
* Splits a parameter list, such as `a: string, b?: { c: number }`, at its top-level commas. Each part keeps its
|
|
377
|
+
* offsets, so a signature can point at the parameter it's on.
|
|
378
|
+
*/
|
|
379
|
+
const splitParameters = (list) => {
|
|
380
|
+
const parts = [];
|
|
381
|
+
let depth = 0;
|
|
382
|
+
let start = 0;
|
|
383
|
+
const push = (end) => {
|
|
384
|
+
const raw = list.slice(start, end);
|
|
385
|
+
const leading = raw.length - raw.trimStart().length;
|
|
386
|
+
const text = raw.trim();
|
|
387
|
+
if (text) parts.push({
|
|
388
|
+
text,
|
|
389
|
+
start: start + leading,
|
|
390
|
+
end: start + leading + text.length
|
|
391
|
+
});
|
|
392
|
+
};
|
|
393
|
+
for (let index = 0; index < list.length; index++) {
|
|
394
|
+
const char = list[index];
|
|
395
|
+
if ("([{<".includes(char)) depth++;
|
|
396
|
+
else if (")]}".includes(char) || char === ">" && list[index - 1] !== "=") depth--;
|
|
397
|
+
else if (char === "," && depth === 0) {
|
|
398
|
+
push(index);
|
|
399
|
+
start = index + 1;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
push(list.length);
|
|
403
|
+
return parts;
|
|
404
|
+
};
|
|
405
|
+
//#endregion
|
|
406
|
+
//#region src/docs.ts
|
|
407
|
+
/**
|
|
408
|
+
* What each built-in does, from its section of the docs site, up to the end of its first example. The docs build writes
|
|
409
|
+
* `docs.json`; see `apps/documentation/src/editor-docs.ts`.
|
|
410
|
+
*/
|
|
411
|
+
const DOCS = {
|
|
412
|
+
tags: {
|
|
413
|
+
"add": {
|
|
414
|
+
"markdown": "- Content goes to the outlet with the same name, in the order it was added. It doesn't matter whether `add to` runs before or after the outlet.\n- The content lines up with the outlet's indentation.\n- The content can read the variables around it. Variables you `set` inside it stay inside it.\n- Content for an outlet the page doesn't have is dropped. A component still works on a page without that outlet, and when you [render one block](https://kilnlang.com/docs/api/rendering/#rendering-one-block).",
|
|
415
|
+
"url": "https://kilnlang.com/docs/tags/outlet/#adding-content"
|
|
416
|
+
},
|
|
417
|
+
"apply": {
|
|
418
|
+
"markdown": "Run a section of output through filters.\n\n`apply` renders its content, then runs the result through one or more filters.\n\n---\n\n*page.kiln*\n\n```kiln\n{% apply upper|trim %}\n Hello, {{ name }}\n{% endapply %}\n```\n\n*Output*\n\n```html\nHELLO, ADA\n```",
|
|
419
|
+
"url": "https://kilnlang.com/docs/tags/apply/"
|
|
420
|
+
},
|
|
421
|
+
"autoescape": {
|
|
422
|
+
"markdown": "Change how output is escaped for a section.\n\n`autoescape` changes how output is [escaped](https://kilnlang.com/docs/language/escaping/) inside a section.\n\n---\n\n*page.kiln*\n\n```kiln\n<script>\n{% autoescape \"js\" %}\n const title = \"{{ page.title }}\";\n{% endautoescape %}\n<\/script>\n```\n\n*Output*\n\n```html\n<script>\nconst title = \"Tom\\u0027s\\u0020\\u0022best\\u0022\\u0020page\";\n<\/script>\n```",
|
|
423
|
+
"url": "https://kilnlang.com/docs/tags/autoescape/"
|
|
424
|
+
},
|
|
425
|
+
"block": {
|
|
426
|
+
"markdown": "- Block names must be unique within a template.\n- `{% endblock title %}` may repeat the name. If it does, it must match.\n- `{% block title page.title %}` is a short form for a block that holds one expression.\n- Layouts can extend other layouts, as deep as you need.",
|
|
427
|
+
"url": "https://kilnlang.com/docs/tags/extends/#blocks"
|
|
428
|
+
},
|
|
429
|
+
"cache": {
|
|
430
|
+
"markdown": "Save a section's output and reuse it in later renders.\n\n`cache` saves the output of a section, so later renders reuse it instead of rendering the section again. Use it for parts that are slow to build, such as long lists or sections that load data.\n\n---\n\n```kiln\n{% cache [\"post\", post.id, post.updatedAt] for 600 %}\n <article>\n <h2>{{ post.title }}</h2>\n {{ post.body|raw }}\n </article>\n{% endcache %}\n```",
|
|
431
|
+
"url": "https://kilnlang.com/docs/tags/cache/"
|
|
432
|
+
},
|
|
433
|
+
"component": {
|
|
434
|
+
"markdown": "Build reusable pieces of markup with props and slots.\n\nA component is a template that takes props and content from its caller. Use it for cards, buttons, dialogs and other reusable pieces with markup inside.\n\n---\n\n*page.kiln*\n\n```kiln\n{% component \"components/card\" with { title: post.title, href: post.url } %}\n <p>{{ post.excerpt }}</p>\n {% fill \"footer\" %}\n <a href=\"{{ post.url }}\">Read more</a>\n {% endfill %}\n{% endcomponent %}\n```\n\n*components/card.kiln*\n\n```kiln\n{% props title, href %}\n<article class=\"card\">\n <h2><a href=\"{{ href }}\">{{ title }}</a></h2>\n {% slot %}{% endslot %}\n {% if slots.footer %}\n <footer>\n {% slot \"footer\" %}{% endslot %}\n </footer>\n {% endif %}\n</article>\n```\n\n*Output*\n\n```html\n<article class=\"card\">\n <h2><a href=\"/blog/hello/\">Hello, world</a></h2>\n <p>Our first post.</p>\n <footer>\n <a href=\"/blog/hello/\">Read more</a>\n </footer>\n</article>\n```",
|
|
435
|
+
"url": "https://kilnlang.com/docs/tags/component/"
|
|
436
|
+
},
|
|
437
|
+
"else": {
|
|
438
|
+
"markdown": "Render content only when a condition holds.\n\n`if` renders its content only when a condition is true. Add `elseif` and `else` for other cases.\n\n---\n\n*page.kiln*\n\n```kiln\n{% if page.hero %}\n <div class=\"hero\">{{ page.hero }}</div>\n{% elseif page.image %}\n <img src=\"{{ page.image }}\" alt=\"\">\n{% else %}\n <hr>\n{% endif %}\n```\n\n*Output*\n\n```html\n<img src=\"/img/cover.jpg\" alt=\"\">\n```",
|
|
439
|
+
"url": "https://kilnlang.com/docs/tags/if/"
|
|
440
|
+
},
|
|
441
|
+
"elseif": {
|
|
442
|
+
"markdown": "Render content only when a condition holds.\n\n`if` renders its content only when a condition is true. Add `elseif` and `else` for other cases.\n\n---\n\n*page.kiln*\n\n```kiln\n{% if page.hero %}\n <div class=\"hero\">{{ page.hero }}</div>\n{% elseif page.image %}\n <img src=\"{{ page.image }}\" alt=\"\">\n{% else %}\n <hr>\n{% endif %}\n```\n\n*Output*\n\n```html\n<img src=\"/img/cover.jpg\" alt=\"\">\n```",
|
|
443
|
+
"url": "https://kilnlang.com/docs/tags/if/"
|
|
444
|
+
},
|
|
445
|
+
"extends": {
|
|
446
|
+
"markdown": "Share a layout between pages.\n\nLayouts hold the markup that pages share. A layout defines named `block` sections, and a page `extends` the layout and fills them in.\n\n---\n\n*page.kiln*\n\n```kiln\n{% extends \"layouts/base\" %}\n\n{% block title %}{{ page.title }} | {{ parent() }}{% endblock %}\n\n{% block content %}\n <h1>{{ page.title }}</h1>\n{% endblock %}\n```\n\n*layouts/base.kiln*\n\n```kiln\n<title>{% block title %}{{ site.name }}{% endblock %}</title>\n<main>\n {% block content %}{% endblock %}\n</main>\n```\n\n*Output*\n\n```html\n<title>About us | Example</title>\n<main>\n <h1>About us</h1>\n</main>\n```",
|
|
447
|
+
"url": "https://kilnlang.com/docs/tags/extends/"
|
|
448
|
+
},
|
|
449
|
+
"fill": {
|
|
450
|
+
"markdown": "- `{% slot %}` outputs the caller's content. Content outside any `fill` goes into this default slot.\n- `{% fill \"name\" %}` in the caller provides content for a named slot, and `{% slot \"name\" %}` in the component outputs it.\n- Content inside `slot` is a fallback, shown when the caller didn't fill that slot. Variables first set in a fallback stay inside it.\n- `slots` is a hash that says which slots were filled, such as `slots.footer`. It includes slots declared by a layout the component extends.\n\n---\n\n```kiln\n<h2>{% slot \"title\" %}Untitled{% endslot %}</h2>\n```",
|
|
451
|
+
"url": "https://kilnlang.com/docs/tags/component/#slots"
|
|
452
|
+
},
|
|
453
|
+
"flush": {
|
|
454
|
+
"markdown": "Close outlets so a streamed page can be sent so far.\n\n`flush` closes the [outlets](https://kilnlang.com/docs/tags/outlet/) above it. When you [stream](https://kilnlang.com/docs/api/streaming/) a page, everything up to `flush` can then be sent, instead of waiting for the render to end.\n\n---\n\n*page.kiln*\n\n```kiln\n<html>\n <head>\n {% outlet \"head\" %}\n {% add to \"head\" %}\n <title>Home</title>\n {% endadd %}\n </head>\n {% flush %}\n <body>\n <h1>Home</h1>\n </body>\n</html>\n```\n\n*Output*\n\n```html\n<html>\n <head>\n <title>Home</title>\n </head>\n <body>\n <h1>Home</h1>\n </body>\n</html>\n```",
|
|
455
|
+
"url": "https://kilnlang.com/docs/tags/flush/"
|
|
456
|
+
},
|
|
457
|
+
"for": {
|
|
458
|
+
"markdown": "Loop over arrays, hashes and other collections.\n\n`for` renders its content once for each item in a collection. An `else` section renders when there are no items.\n\n---\n\n*page.kiln*\n\n```kiln\n{% for post in posts %}\n <h2>{{ loop.index }}. {{ post.title }}</h2>\n{% else %}\n <p>No posts yet.</p>\n{% endfor %}\n```\n\n*Output*\n\n```html\n<h2>1. Hello</h2>\n<h2>2. Second post</h2>\n```",
|
|
459
|
+
"url": "https://kilnlang.com/docs/tags/for/"
|
|
460
|
+
},
|
|
461
|
+
"from": {
|
|
462
|
+
"markdown": "`import` gives you all of a template's macros under one name. `from` imports chosen macros by name, optionally with an alias:\n\n---\n\n```kiln\n{% import \"macros/forms\" as forms %}\n{% from \"macros/forms\" import input, textarea as area %}\n```",
|
|
463
|
+
"url": "https://kilnlang.com/docs/tags/macro/#importing"
|
|
464
|
+
},
|
|
465
|
+
"if": {
|
|
466
|
+
"markdown": "Render content only when a condition holds.\n\n`if` renders its content only when a condition is true. Add `elseif` and `else` for other cases.\n\n---\n\n*page.kiln*\n\n```kiln\n{% if page.hero %}\n <div class=\"hero\">{{ page.hero }}</div>\n{% elseif page.image %}\n <img src=\"{{ page.image }}\" alt=\"\">\n{% else %}\n <hr>\n{% endif %}\n```\n\n*Output*\n\n```html\n<img src=\"/img/cover.jpg\" alt=\"\">\n```",
|
|
467
|
+
"url": "https://kilnlang.com/docs/tags/if/"
|
|
468
|
+
},
|
|
469
|
+
"import": {
|
|
470
|
+
"markdown": "`import` gives you all of a template's macros under one name. `from` imports chosen macros by name, optionally with an alias:\n\n---\n\n```kiln\n{% import \"macros/forms\" as forms %}\n{% from \"macros/forms\" import input, textarea as area %}\n```",
|
|
471
|
+
"url": "https://kilnlang.com/docs/tags/macro/#importing"
|
|
472
|
+
},
|
|
473
|
+
"include": {
|
|
474
|
+
"markdown": "Render another template in place.\n\n`include` renders another template in place. Pass it variables with `with`.\n\n---\n\n*page.kiln*\n\n```kiln\n{% include \"partials/header\" with { title: page.title } %}\n```\n\n*partials/header.kiln*\n\n```kiln\n<header>\n <a href=\"/\">{{ site.name }}</a>\n <span>{{ title }}</span>\n</header>\n```\n\n*Output*\n\n```html\n<header>\n <a href=\"/\">Example</a>\n <span>Pricing</span>\n</header>\n```",
|
|
475
|
+
"url": "https://kilnlang.com/docs/tags/include/"
|
|
476
|
+
},
|
|
477
|
+
"island": {
|
|
478
|
+
"markdown": "Load part of a page after the rest, for live or slow data.\n\nAn island is a component that can load after the rest of the page. Use islands in a site you build to static files, for the few parts that need live data, such as stock levels, a basket or a visitor's name. On a server, use them to send the page before its slowest parts are ready.\n\n---\n\n```kiln\n{% island \"components/stock\" with { productId: product.id } %}\n <p>Checking stock…</p>\n{% endisland %}\n```",
|
|
479
|
+
"url": "https://kilnlang.com/docs/tags/island/"
|
|
480
|
+
},
|
|
481
|
+
"macro": {
|
|
482
|
+
"markdown": "Define small reusable snippets you call like functions.\n\nA macro is a small piece of markup you call like a function. Define macros in a template, then import them where you need them.\n\n---\n\n*page.kiln*\n\n```kiln\n{% import \"macros/forms\" as forms %}\n\n{{ forms.input(\"email\", type: \"email\") }}\n{{ forms.input(\"name\", \"Ada\") }}\n```\n\n*macros/forms.kiln*\n\n```kiln\n{% macro input(name, value = \"\", type = \"text\") -%}\n <input type=\"{{ type }}\" name=\"{{ name }}\" value=\"{{ value }}\">\n{%- endmacro %}\n```\n\n*Output*\n\n```html\n<input type=\"email\" name=\"email\" value=\"\">\n<input type=\"text\" name=\"name\" value=\"Ada\">\n```",
|
|
483
|
+
"url": "https://kilnlang.com/docs/tags/macro/"
|
|
484
|
+
},
|
|
485
|
+
"outlet": {
|
|
486
|
+
"markdown": "Let components add styles and scripts to the page's head.\n\n`outlet` marks a place in the page that any template can add to, and `add to` adds content there. Use them so a component can bring its own styles and scripts, and they end up in the page's `<head>`.\n\n---\n\n*page.kiln*\n\n```kiln\n{% extends \"layouts/base\" %}\n{% set title = \"Home\" %}\n{% block content %}\n {% include \"components/card\" with { title: \"One\" } %}\n {% include \"components/card\" with { title: \"Two\" } %}\n{% endblock %}\n```\n\n*layouts/base.kiln*\n\n```kiln\n<html>\n <head>\n <title>{{ title }}</title>\n {% outlet \"head\" %}\n </head>\n <body>\n {% block content %}{% endblock %}\n </body>\n</html>\n```\n\n*components/card.kiln*\n\n```kiln\n{% props title %}\n{% add to \"head\" once %}\n <link rel=\"stylesheet\" href=\"/card.css\">\n{% endadd %}\n<article class=\"card\">{{ title }}</article>\n```\n\n*Output*\n\n```html\n<html>\n <head>\n <title>Home</title>\n <link rel=\"stylesheet\" href=\"/card.css\">\n </head>\n <body>\n <article class=\"card\">One</article>\n <article class=\"card\">Two</article>\n </body>\n</html>\n```",
|
|
487
|
+
"url": "https://kilnlang.com/docs/tags/outlet/"
|
|
488
|
+
},
|
|
489
|
+
"props": {
|
|
490
|
+
"markdown": "Declare the variables a template expects.\n\n`props` declares the variables a template expects, with optional types and defaults. It is most useful in components and included templates, where it documents and checks their inputs.\n\n---\n\n*page.kiln*\n\n```kiln\n{% include \"components/button\" with { label: \"Sign up\", href: \"/join\" } %}\n{% include \"components/button\" with { label: \"Cancel\", variant: \"secondary\" } %}\n```\n\n*components/button.kiln*\n\n```kiln\n{% props label: string, href: string | null = null, variant: \"primary\" | \"secondary\" = \"primary\" %}\n{% if href %}\n <a class=\"button button--{{ variant }}\" href=\"{{ href }}\">{{ label }}</a>\n{% else %}\n <button class=\"button button--{{ variant }}\">{{ label }}</button>\n{% endif %}\n```\n\n*Output*\n\n```html\n<a class=\"button button--primary\" href=\"/join\">Sign up</a>\n<button class=\"button button--secondary\">Cancel</button>\n```",
|
|
491
|
+
"url": "https://kilnlang.com/docs/tags/props/"
|
|
492
|
+
},
|
|
493
|
+
"set": {
|
|
494
|
+
"markdown": "Assign a value or captured markup to a variable.\n\n`set` assigns a value to a variable.\n\n---\n\n*page.kiln*\n\n```kiln\n{% set title = page.title ?? \"Untitled\" %}\n<h1>{{ title }}</h1>\n```\n\n*Output*\n\n```html\n<h1>Untitled</h1>\n```",
|
|
495
|
+
"url": "https://kilnlang.com/docs/tags/set/"
|
|
496
|
+
},
|
|
497
|
+
"slot": {
|
|
498
|
+
"markdown": "- `{% slot %}` outputs the caller's content. Content outside any `fill` goes into this default slot.\n- `{% fill \"name\" %}` in the caller provides content for a named slot, and `{% slot \"name\" %}` in the component outputs it.\n- Content inside `slot` is a fallback, shown when the caller didn't fill that slot. Variables first set in a fallback stay inside it.\n- `slots` is a hash that says which slots were filled, such as `slots.footer`. It includes slots declared by a layout the component extends.\n\n---\n\n```kiln\n<h2>{% slot \"title\" %}Untitled{% endslot %}</h2>\n```",
|
|
499
|
+
"url": "https://kilnlang.com/docs/tags/component/#slots"
|
|
500
|
+
},
|
|
501
|
+
"verbatim": {
|
|
502
|
+
"markdown": "Output Kiln syntax without running it.\n\n`verbatim` outputs its content as plain text, without running any Kiln syntax inside it.\n\n---\n\n*page.kiln*\n\n```kiln\n{% verbatim %}\n<p>Write {{ name }} to output a variable.</p>\n{% endverbatim %}\n```\n\n*Output*\n\n```html\n<p>Write {{ name }} to output a variable.</p>\n```",
|
|
503
|
+
"url": "https://kilnlang.com/docs/tags/verbatim/"
|
|
504
|
+
},
|
|
505
|
+
"with": {
|
|
506
|
+
"markdown": "Add variables for a section of a template.\n\n`with` adds variables for a section of a template. They disappear at `endwith`.\n\n---\n\n*page.kiln*\n\n```kiln\n{% with { heading: \"Latest\", items: posts|slice(0, 3) } %}\n <h2>{{ heading }}</h2>\n {% for item in items %}\n <p>{{ item.title }}</p>\n {% endfor %}\n{% endwith %}\n```\n\n*Output*\n\n```html\n<h2>Latest</h2>\n<p>One</p>\n<p>Two</p>\n<p>Three</p>\n```",
|
|
507
|
+
"url": "https://kilnlang.com/docs/tags/with/"
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
filters: {
|
|
511
|
+
"abs": {
|
|
512
|
+
"markdown": "Returns the absolute value of a number.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ (-5)|abs }}\n```\n\n*Output*\n\n```html\n5\n```",
|
|
513
|
+
"url": "https://kilnlang.com/docs/filters/numbers-and-dates/#abs"
|
|
514
|
+
},
|
|
515
|
+
"batch": {
|
|
516
|
+
"markdown": "Splits an array into groups of a given size. Pass a second argument to fill up the last group.\n\n---\n\n*page.kiln*\n\n```kiln\n{% for row in [1, 2, 3, 4, 5]|batch(2, \"-\") %}\n <div>{{ row|join(\" \") }}</div>\n{% endfor %}\n```\n\n*Output*\n\n```html\n<div>1 2</div>\n<div>3 4</div>\n<div>5 -</div>\n```",
|
|
517
|
+
"url": "https://kilnlang.com/docs/filters/lists/#batch"
|
|
518
|
+
},
|
|
519
|
+
"capitalize": {
|
|
520
|
+
"markdown": "Makes the first character upper case and the rest lower case.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ \"hELLO wORLD\"|capitalize }}\n```\n\n*Output*\n\n```html\nHello world\n```",
|
|
521
|
+
"url": "https://kilnlang.com/docs/filters/strings/#capitalize"
|
|
522
|
+
},
|
|
523
|
+
"column": {
|
|
524
|
+
"markdown": "Reads one property from each item.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ posts|column(\"author\")|join(\", \") }}\n```\n\n*Output*\n\n```html\nAda, Grace, Ada\n```",
|
|
525
|
+
"url": "https://kilnlang.com/docs/filters/lists/#column"
|
|
526
|
+
},
|
|
527
|
+
"default": {
|
|
528
|
+
"markdown": "Returns a fallback when the value is [empty](https://kilnlang.com/docs/language/values/#empty-values) or missing. In strict mode, it also stops a missing value from being an error.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ page.subtitle|default(\"No subtitle\") }}\n```\n\n*Output*\n\n```html\nNo subtitle\n```",
|
|
529
|
+
"url": "https://kilnlang.com/docs/filters/lists/#default"
|
|
530
|
+
},
|
|
531
|
+
"escape": {
|
|
532
|
+
"markdown": "Escapes a value with a strategy: `\"html\"` (the default), `\"attr\"`, `\"js\"`, `\"css\"` or `\"url\"`. See [Escaping](https://kilnlang.com/docs/language/escaping/).\n\n---\n\n*page.kiln*\n\n```kiln\n<a href=\"/tags/{{ \"a&b\"|escape(\"url\") }}\">Tag</a>\n```\n\n*Output*\n\n```html\n<a href=\"/tags/a%26b\">Tag</a>\n```",
|
|
533
|
+
"url": "https://kilnlang.com/docs/filters/strings/#escape"
|
|
534
|
+
},
|
|
535
|
+
"filter": {
|
|
536
|
+
"markdown": "Keeps the items for which the arrow function returns true.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ posts|filter(p => p.author == \"Ada\")|column(\"title\")|join(\", \") }}\n```\n\n*Output*\n\n```html\nHello, Workers\n```",
|
|
537
|
+
"url": "https://kilnlang.com/docs/filters/lists/#filter"
|
|
538
|
+
},
|
|
539
|
+
"find": {
|
|
540
|
+
"markdown": "Returns the first item for which the arrow function returns true, or null.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ (posts|find(p => p.views > 200)).title }}\n```\n\n*Output*\n\n```html\nLayouts\n```",
|
|
541
|
+
"url": "https://kilnlang.com/docs/filters/lists/#find"
|
|
542
|
+
},
|
|
543
|
+
"first": {
|
|
544
|
+
"markdown": "Return the first or last item, or null when there are none. On a string they return a character.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ (posts|first).title }} and {{ (posts|last).title }}\n```\n\n*Output*\n\n```html\nHello and Workers\n```",
|
|
545
|
+
"url": "https://kilnlang.com/docs/filters/lists/#first-and-last"
|
|
546
|
+
},
|
|
547
|
+
"formatDate": {
|
|
548
|
+
"markdown": "Formats a date with `Intl.DateTimeFormat`. It accepts a `Date`, an ISO 8601 string or milliseconds since 1970. A day past the end of its month, such as `2026-02-30`, is an error. Pass any of its options as named arguments, plus `locale` and `timeZone`. Option values must be strings, numbers or booleans. The default style is `dateStyle: \"medium\"`, unless you pass an option that chooses what to show, such as `timeStyle`, `month` or `hour`.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ \"2026-10-15\"|formatDate }}\n{{ \"2026-10-15\"|formatDate(dateStyle: \"long\", locale: \"en-GB\") }}\n{{ \"2026-10-15T14:30:00Z\"|formatDate(timeStyle: \"short\", timeZone: \"Europe/London\") }}\n{{ \"2026-10-15\"|formatDate(weekday: \"long\", day: \"numeric\", month: \"long\") }}\n```\n\n*Output*\n\n```html\nOct 15, 2026\n15 October 2026\n3:30 PM\nThursday, October 15\n```",
|
|
549
|
+
"url": "https://kilnlang.com/docs/filters/numbers-and-dates/#formatdate"
|
|
550
|
+
},
|
|
551
|
+
"formatNumber": {
|
|
552
|
+
"markdown": "Formats a number with `Intl.NumberFormat`. Pass any of its options as named arguments, plus `locale`. Option values must be strings, numbers or booleans, and an option name `Intl` doesn't know is an error.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ 1234567.891|formatNumber }}\n{{ 49.5|formatNumber(style: \"currency\", currency: \"GBP\") }}\n{{ 0.256|formatNumber(style: \"percent\") }}\n{{ 1234.5|formatNumber(locale: \"de-DE\", minimumFractionDigits: 2) }}\n```\n\n*Output*\n\n```html\n1,234,567.891\n£49.50\n26%\n1.234,50\n```",
|
|
553
|
+
"url": "https://kilnlang.com/docs/filters/numbers-and-dates/#formatnumber"
|
|
554
|
+
},
|
|
555
|
+
"join": {
|
|
556
|
+
"markdown": "Joins items into a string. A second separator goes between the last two items.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ [\"tea\", \"coffee\", \"water\"]|join(\", \", \" and \") }}\n```\n\n*Output*\n\n```html\ntea, coffee and water\n```",
|
|
557
|
+
"url": "https://kilnlang.com/docs/filters/lists/#join"
|
|
558
|
+
},
|
|
559
|
+
"json": {
|
|
560
|
+
"markdown": "Turns a value into JSON. Pass a number to indent it. Sets, generators and other iterables become arrays. The output escapes `<`, `>` and `&`, so it is safe inside a `<script>` tag with `raw`.\n\n---\n\n*page.kiln*\n\n```kiln\n<script>const page = {{ { title: \"Home\", tags: [\"a\", \"b\"] }|json|raw }};<\/script>\n```\n\n*Output*\n\n```html\n<script>const page = {\"title\":\"Home\",\"tags\":[\"a\",\"b\"]};<\/script>\n```",
|
|
561
|
+
"url": "https://kilnlang.com/docs/filters/strings/#json"
|
|
562
|
+
},
|
|
563
|
+
"keys": {
|
|
564
|
+
"markdown": "Returns the keys of a hash or map, or the indexes of an array.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ { a: 1, b: 2 }|keys|join(\", \") }}\n```\n\n*Output*\n\n```html\na, b\n```",
|
|
565
|
+
"url": "https://kilnlang.com/docs/filters/lists/#keys"
|
|
566
|
+
},
|
|
567
|
+
"last": {
|
|
568
|
+
"markdown": "Return the first or last item, or null when there are none. On a string they return a character.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ (posts|first).title }} and {{ (posts|last).title }}\n```\n\n*Output*\n\n```html\nHello and Workers\n```",
|
|
569
|
+
"url": "https://kilnlang.com/docs/filters/lists/#first-and-last"
|
|
570
|
+
},
|
|
571
|
+
"length": {
|
|
572
|
+
"markdown": "Counts the items in an array, the keys in a hash, or the characters in a string. A hash or map counts the entries a loop would see. Null has a length of 0.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ posts|length }} posts, {{ \"héllo\"|length }} letters\n```\n\n*Output*\n\n```html\n3 posts, 5 letters\n```",
|
|
573
|
+
"url": "https://kilnlang.com/docs/filters/lists/#length"
|
|
574
|
+
},
|
|
575
|
+
"lower": {
|
|
576
|
+
"markdown": "Change text to upper or lower case.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ \"Hello\"|upper }} {{ \"Hello\"|lower }}\n```\n\n*Output*\n\n```html\nHELLO hello\n```",
|
|
577
|
+
"url": "https://kilnlang.com/docs/filters/strings/#upper-and-lower"
|
|
578
|
+
},
|
|
579
|
+
"map": {
|
|
580
|
+
"markdown": "Calls an arrow function for each item and returns the results. The function gets the value and the key.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ posts|map(p => p.title|upper)|join(\", \") }}\n```\n\n*Output*\n\n```html\nHELLO, LAYOUTS, WORKERS\n```",
|
|
581
|
+
"url": "https://kilnlang.com/docs/filters/lists/#map"
|
|
582
|
+
},
|
|
583
|
+
"merge": {
|
|
584
|
+
"markdown": "Joins arrays together, or combines hashes. For hashes, later keys win. Mixing arrays and hashes is an error.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ [1, 2]|merge([3], [4])|join(\", \") }}\n{{ ({ size: \"md\", tone: \"plain\" }|merge({ size: \"lg\" })).size }}\n```\n\n*Output*\n\n```html\n1, 2, 3, 4\nlg\n```",
|
|
585
|
+
"url": "https://kilnlang.com/docs/filters/lists/#merge"
|
|
586
|
+
},
|
|
587
|
+
"nl2br": {
|
|
588
|
+
"markdown": "Escapes the text, then adds a `<br>` before each line break. Safe markup isn't escaped again. The result is safe HTML.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ \"Line one\nLine two\"|nl2br }}\n```\n\n*Output*\n\n```html\nLine one<br>\nLine two\n```",
|
|
589
|
+
"url": "https://kilnlang.com/docs/filters/strings/#nl2br"
|
|
590
|
+
},
|
|
591
|
+
"plural": {
|
|
592
|
+
"markdown": "Picks the word or phrase that fits a number, using the plural rules of the render's locale. Pass the forms as named arguments. Every `#` in the chosen form becomes the number, formatted for the locale.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ 1|plural(one: \"# item\", other: \"# items\") }}\n{{ 1200|plural(one: \"# item\", other: \"# items\") }}\n{{ 3|plural(\"item\", \"items\") }}\n```\n\n*Output*\n\n```html\n1 item\n1,200 items\nitems\n```",
|
|
593
|
+
"url": "https://kilnlang.com/docs/filters/numbers-and-dates/#plural"
|
|
594
|
+
},
|
|
595
|
+
"raw": {
|
|
596
|
+
"markdown": "Marks a value as safe, so it is output without escaping. Only use it for HTML you trust. Put it after filters that return plain values, such as `split` and `join`.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ html|raw }}\n```\n\n*Output*\n\n```html\n<em>Trusted</em>\n```",
|
|
597
|
+
"url": "https://kilnlang.com/docs/filters/strings/#raw"
|
|
598
|
+
},
|
|
599
|
+
"reduce": {
|
|
600
|
+
"markdown": "Combines the items into one value. The arrow function gets the running total, the value and the key. The second argument is the starting value. Without it, the total starts as null, which can't be added to, so pass 0 to add up numbers.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ posts|reduce((total, p) => total + p.views, 0) }} views\n```\n\n*Output*\n\n```html\n550 views\n```",
|
|
601
|
+
"url": "https://kilnlang.com/docs/filters/lists/#reduce"
|
|
602
|
+
},
|
|
603
|
+
"replace": {
|
|
604
|
+
"markdown": "Replaces every match of a piece of text. Pass a hash to replace several at once, and the longest match wins at each position. It matches plain text, not regular expressions.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ \"I like tea\"|replace(\"tea\", \"coffee\") }}\n{{ \"Hi NAME, welcome to PLACE\"|replace({ NAME: \"Ada\", PLACE: \"Kiln\" }) }}\n```\n\n*Output*\n\n```html\nI like coffee\nHi Ada, welcome to Kiln\n```",
|
|
605
|
+
"url": "https://kilnlang.com/docs/filters/strings/#replace"
|
|
606
|
+
},
|
|
607
|
+
"reverse": {
|
|
608
|
+
"markdown": "Reverses an array, string or hash. A hash keeps its keys.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ [1, 2, 3]|reverse|join(\", \") }} {{ \"kiln\"|reverse }}\n```\n\n*Output*\n\n```html\n3, 2, 1 nlik\n```",
|
|
609
|
+
"url": "https://kilnlang.com/docs/filters/lists/#reverse"
|
|
610
|
+
},
|
|
611
|
+
"round": {
|
|
612
|
+
"markdown": "Rounds to a number of decimal places, 0 by default. The second argument is the method: `\"common\"` (the default, which rounds halves away from zero), `\"ceil\"` or `\"floor\"`.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ 3.14159|round(2) }}\n{{ 2.5|round }}\n{{ 2.1|round(0, \"ceil\") }}\n```\n\n*Output*\n\n```html\n3.14\n3\n3\n```",
|
|
613
|
+
"url": "https://kilnlang.com/docs/filters/numbers-and-dates/#round"
|
|
614
|
+
},
|
|
615
|
+
"slice": {
|
|
616
|
+
"markdown": "Returns part of an array or string, from a start index up to, but not including, an end index. Negative numbers count from the end, as in JavaScript.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ [1, 2, 3, 4, 5]|slice(1, 3)|join(\", \") }}\n{{ [1, 2, 3, 4, 5]|slice(-2)|join(\", \") }}\n```\n\n*Output*\n\n```html\n2, 3\n4, 5\n```",
|
|
617
|
+
"url": "https://kilnlang.com/docs/filters/lists/#slice"
|
|
618
|
+
},
|
|
619
|
+
"sort": {
|
|
620
|
+
"markdown": "Returns a sorted copy. Without an argument, it sorts numbers, strings or dates in natural order. With a one-parameter arrow function, it sorts by the result. With a two-parameter one, it uses it to compare items, like JavaScript's `sort`. A hash keeps its keys, sorted by their values.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ [3, 1, 2]|sort|join(\", \") }}\n{{ posts|sort(p => p.date)|column(\"title\")|join(\", \") }}\n{{ posts|sort((a, b) => b.views - a.views)|column(\"title\")|join(\", \") }}\n```\n\n*Output*\n\n```html\n1, 2, 3\nLayouts, Workers, Hello\nLayouts, Hello, Workers\n```",
|
|
621
|
+
"url": "https://kilnlang.com/docs/filters/lists/#sort"
|
|
622
|
+
},
|
|
623
|
+
"split": {
|
|
624
|
+
"markdown": "Splits text into an array. An optional limit caps the number of items. An empty separator splits into characters.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ \"a,b,c\"|split(\",\")|join(\" / \") }}\n{{ \"a,b,c\"|split(\",\", 2)|join(\" / \") }}\n```\n\n*Output*\n\n```html\na / b / c\na / b\n```",
|
|
625
|
+
"url": "https://kilnlang.com/docs/filters/strings/#split"
|
|
626
|
+
},
|
|
627
|
+
"stripTags": {
|
|
628
|
+
"markdown": "Removes HTML tags and comments. It doesn't decode entities such as `&amp;`, and removing a tag never leaves a new one behind. Its result is still escaped, so don't mark it with `raw` to make it safe.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ \"<p>Hello <b>world</b></p>\"|stripTags }}\n```\n\n*Output*\n\n```html\nHello world\n```",
|
|
629
|
+
"url": "https://kilnlang.com/docs/filters/strings/#striptags"
|
|
630
|
+
},
|
|
631
|
+
"title": {
|
|
632
|
+
"markdown": "Starts each word with an upper-case letter and makes the rest lower case. Accent marks and apostrophes belong to the word they are in, so \"o'neil\" becomes \"O'neil\".\n\n---\n\n*page.kiln*\n\n```kiln\n{{ \"the QUICK brown fox\"|title }}\n```\n\n*Output*\n\n```html\nThe Quick Brown Fox\n```",
|
|
633
|
+
"url": "https://kilnlang.com/docs/filters/strings/#title"
|
|
634
|
+
},
|
|
635
|
+
"trim": {
|
|
636
|
+
"markdown": "Removes whitespace from both ends. Pass characters to remove instead, and `\"start\"` or `\"end\"` to trim one side.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ \" hello \"|trim }}|{{ \"--hello--\"|trim(\"-\", \"end\") }}\n```\n\n*Output*\n\n```html\nhello|--hello\n```",
|
|
637
|
+
"url": "https://kilnlang.com/docs/filters/strings/#trim"
|
|
638
|
+
},
|
|
639
|
+
"truncate": {
|
|
640
|
+
"markdown": "Shortens text to a length, trims trailing spaces and adds an ellipsis if it cut anything. Pass a different ending as the second argument. Given safe markup, it counts only the text and closes any tags the cut leaves open.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ \"Kiln is a template language\"|truncate(13) }}\n{{ \"Short\"|truncate(13) }}\n```\n\n*Output*\n\n```html\nKiln is a tem…\nShort\n```",
|
|
641
|
+
"url": "https://kilnlang.com/docs/filters/strings/#truncate"
|
|
642
|
+
},
|
|
643
|
+
"upper": {
|
|
644
|
+
"markdown": "Change text to upper or lower case.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ \"Hello\"|upper }} {{ \"Hello\"|lower }}\n```\n\n*Output*\n\n```html\nHELLO hello\n```",
|
|
645
|
+
"url": "https://kilnlang.com/docs/filters/strings/#upper-and-lower"
|
|
646
|
+
},
|
|
647
|
+
"urlEncode": {
|
|
648
|
+
"markdown": "Encodes a string for use in a URL, or turns a hash into a query string.\n\n---\n\n*page.kiln*\n\n```kiln\n/search?q={{ \"tea & cake\"|urlEncode }}\n/search?{{ { q: \"tea\", page: 2 }|urlEncode }}\n```\n\n*Output*\n\n```html\n/search?q=tea%20%26%20cake\n/search?q=tea&page=2\n```",
|
|
649
|
+
"url": "https://kilnlang.com/docs/filters/strings/#urlencode"
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
functions: {
|
|
653
|
+
"attrs": {
|
|
654
|
+
"markdown": "Builds HTML attributes from a hash. It handles the fiddly cases for you:\n\n- null and `false` leave the attribute out;\n- `true` writes the name on its own;\n- arrays are joined with spaces, skipping empty values;\n- everything else is escaped.\n\n---\n\n*page.kiln*\n\n```kiln\n<a{{ attrs({\n href: post.url,\n class: [\"card\", featured ? \"card--featured\"],\n hidden: false,\n \"aria-current\": current ? \"page\",\n}) }}>{{ post.title }}</a>\n```\n\n*Output*\n\n```html\n<a href=\"/blog/hello/\" class=\"card card--featured\">Hello</a>\n```",
|
|
655
|
+
"url": "https://kilnlang.com/docs/functions/#attrs"
|
|
656
|
+
},
|
|
657
|
+
"block": {
|
|
658
|
+
"markdown": "`parent()` outputs the layout's version of the current block. `block(\"name\")` outputs a block of the current template. See [extends and block](https://kilnlang.com/docs/tags/extends/#reusing-blocks).",
|
|
659
|
+
"url": "https://kilnlang.com/docs/functions/#parent-and-block"
|
|
660
|
+
},
|
|
661
|
+
"cycle": {
|
|
662
|
+
"markdown": "Returns the item at a position, starting again from the beginning when it runs out. Useful for alternating classes.\n\n---\n\n*page.kiln*\n\n```kiln\n{% for item in [\"a\", \"b\", \"c\"] %}\n <li class=\"{{ cycle([\"odd\", \"even\"], loop.index0) }}\">{{ item }}</li>\n{% endfor %}\n```\n\n*Output*\n\n```html\n<li class=\"odd\">a</li>\n<li class=\"even\">b</li>\n<li class=\"odd\">c</li>\n```",
|
|
663
|
+
"url": "https://kilnlang.com/docs/functions/#cycle"
|
|
664
|
+
},
|
|
665
|
+
"date": {
|
|
666
|
+
"markdown": "Returns a `Date` from a date, an ISO 8601 string or milliseconds. With no argument, it returns the current time. With null, it returns null, so a missing date never becomes today.\n\n---\n\n*page.kiln*\n\n```kiln\n{% if date(post.publishedAt) < date(\"2026-06-01\") %}Older post{% endif %}\n```\n\n*Output*\n\n```html\nOlder post\n```",
|
|
667
|
+
"url": "https://kilnlang.com/docs/functions/#date"
|
|
668
|
+
},
|
|
669
|
+
"dump": {
|
|
670
|
+
"markdown": "Outputs a readable view of its arguments, for debugging. It only works when the environment has `debug: true`. Otherwise it outputs nothing, so a forgotten `dump` never leaks data.\n\n---\n\n```kiln\n{{ dump(page, route) }}\n```",
|
|
671
|
+
"url": "https://kilnlang.com/docs/functions/#dump"
|
|
672
|
+
},
|
|
673
|
+
"max": {
|
|
674
|
+
"markdown": "Return the largest or smallest argument, the largest or smallest item of one array, or the largest or smallest value of one hash. Null can't be compared, so it is an error.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ max(3, 8, 1) }} {{ min([4, 2, 9]) }}\n```\n\n*Output*\n\n```html\n8 2\n```",
|
|
675
|
+
"url": "https://kilnlang.com/docs/functions/#max-and-min"
|
|
676
|
+
},
|
|
677
|
+
"min": {
|
|
678
|
+
"markdown": "Return the largest or smallest argument, the largest or smallest item of one array, or the largest or smallest value of one hash. Null can't be compared, so it is an error.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ max(3, 8, 1) }} {{ min([4, 2, 9]) }}\n```\n\n*Output*\n\n```html\n8 2\n```",
|
|
679
|
+
"url": "https://kilnlang.com/docs/functions/#max-and-min"
|
|
680
|
+
},
|
|
681
|
+
"parent": {
|
|
682
|
+
"markdown": "`parent()` outputs the layout's version of the current block. `block(\"name\")` outputs a block of the current template. See [extends and block](https://kilnlang.com/docs/tags/extends/#reusing-blocks).",
|
|
683
|
+
"url": "https://kilnlang.com/docs/functions/#parent-and-block"
|
|
684
|
+
},
|
|
685
|
+
"range": {
|
|
686
|
+
"markdown": "Returns an array of numbers from a start to an end, including both. The optional third argument is the step. A fractional step, such as 0.1, still reaches the end.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ range(1, 5)|join(\", \") }}\n{{ range(0, 10, 5)|join(\", \") }}\n{{ range(3, 1)|join(\", \") }}\n{{ range(0, 0.3, 0.1)|join(\", \") }}\n```\n\n*Output*\n\n```html\n1, 2, 3, 4, 5\n0, 5, 10\n3, 2, 1\n0, 0.1, 0.2, 0.3\n```",
|
|
687
|
+
"url": "https://kilnlang.com/docs/functions/#range"
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
tests: {
|
|
691
|
+
"defined": {
|
|
692
|
+
"markdown": "True when a variable or property exists. It never raises a [strict mode](https://kilnlang.com/docs/language/strict-mode/) error, so it is the safe way to check for optional data.\n\n---\n\n*page.kiln*\n\n```kiln\n{% if page.subtitle is defined %}{{ page.subtitle }}{% else %}No subtitle{% endif %}\n```\n\n*Output*\n\n```html\nNo subtitle\n```",
|
|
693
|
+
"url": "https://kilnlang.com/docs/tests/#defined"
|
|
694
|
+
},
|
|
695
|
+
"divisible by": {
|
|
696
|
+
"markdown": "True when the value is a multiple of the argument.\n\n---\n\n*page.kiln*\n\n```kiln\n{% for n in 1..6 %}{{ n }}{% if n is divisible by(3) %} |{% endif %} {% endfor %}\n```\n\n*Output*\n\n```html\n1 2 3 | 4 5 6 |\n```",
|
|
697
|
+
"url": "https://kilnlang.com/docs/tests/#divisible-by"
|
|
698
|
+
},
|
|
699
|
+
"empty": {
|
|
700
|
+
"markdown": "True when the value is [empty](https://kilnlang.com/docs/language/values/#empty-values): null, `false`, an empty string, array, hash, set or map. `0` is not empty.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ [] is empty }} {{ 0 is empty }}\n```\n\n*Output*\n\n```html\ntrue false\n```",
|
|
701
|
+
"url": "https://kilnlang.com/docs/tests/#empty"
|
|
702
|
+
},
|
|
703
|
+
"even": {
|
|
704
|
+
"markdown": "True when the value is an even or odd whole number.\n\n---\n\n*page.kiln*\n\n```kiln\n{% for n in 1..4 %}{{ n }} is {{ n is even ? \"even\" : \"odd\" }}. {% endfor %}\n```\n\n*Output*\n\n```html\n1 is odd. 2 is even. 3 is odd. 4 is even.\n```",
|
|
705
|
+
"url": "https://kilnlang.com/docs/tests/#even-and-odd"
|
|
706
|
+
},
|
|
707
|
+
"iterable": {
|
|
708
|
+
"markdown": "True for arrays, hashes, sets, maps and other values `for` can loop over. Strings are not iterable.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ [1, 2] is iterable }} {{ \"abc\" is iterable }}\n```\n\n*Output*\n\n```html\ntrue false\n```",
|
|
709
|
+
"url": "https://kilnlang.com/docs/tests/#iterable"
|
|
710
|
+
},
|
|
711
|
+
"null": {
|
|
712
|
+
"markdown": "True when the value is null or undefined.\n\n---\n\n*page.kiln*\n\n```kiln\n{{ page.image is null ? \"No image\" : \"Has image\" }}\n```\n\n*Output*\n\n```html\nNo image\n```",
|
|
713
|
+
"url": "https://kilnlang.com/docs/tests/#null"
|
|
714
|
+
},
|
|
715
|
+
"odd": {
|
|
716
|
+
"markdown": "True when the value is an even or odd whole number.\n\n---\n\n*page.kiln*\n\n```kiln\n{% for n in 1..4 %}{{ n }} is {{ n is even ? \"even\" : \"odd\" }}. {% endfor %}\n```\n\n*Output*\n\n```html\n1 is odd. 2 is even. 3 is odd. 4 is even.\n```",
|
|
717
|
+
"url": "https://kilnlang.com/docs/tests/#even-and-odd"
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
/** A built-in's docs as Markdown, ending with a link to its section on the docs site. Null for names without docs. */
|
|
722
|
+
const docsFor = (kind, name) => {
|
|
723
|
+
const doc = DOCS[kind][name];
|
|
724
|
+
return doc ? `${doc.markdown}\n\n---\n\n[Kiln docs](${doc.url})` : null;
|
|
725
|
+
};
|
|
726
|
+
//#endregion
|
|
727
|
+
//#region src/html.ts
|
|
728
|
+
const service = getLanguageService();
|
|
729
|
+
/** The template as an HTML document, with Kiln syntax blanked out so the HTML service only sees the markup. */
|
|
730
|
+
const htmlOf = (document) => {
|
|
731
|
+
const html = TextDocument.create(document.uri, "html", document.version, templateText(document.getText()));
|
|
732
|
+
return {
|
|
733
|
+
html,
|
|
734
|
+
parsed: service.parseHTMLDocument(html)
|
|
735
|
+
};
|
|
736
|
+
};
|
|
737
|
+
/** HTML elements, attributes and attribute values at `offset`, outside Kiln syntax. */
|
|
738
|
+
const htmlCompletions = (document, offset) => {
|
|
739
|
+
const { html, parsed } = htmlOf(document);
|
|
740
|
+
return service.doComplete(html, document.positionAt(offset), parsed).items;
|
|
741
|
+
};
|
|
742
|
+
/** MDN's description of the HTML element or attribute at `offset`, outside Kiln syntax. */
|
|
743
|
+
const htmlHover = (document, offset) => {
|
|
744
|
+
const { html, parsed } = htmlOf(document);
|
|
745
|
+
return service.doHover(html, document.positionAt(offset), parsed);
|
|
746
|
+
};
|
|
747
|
+
//#endregion
|
|
748
|
+
//#region src/typescript-6.ts
|
|
749
|
+
/** A project checked by TypeScript 6's classic API, in this process. */
|
|
750
|
+
const openTypeScript6 = (ts, configPath) => {
|
|
751
|
+
const parseConfig = () => {
|
|
752
|
+
const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, {
|
|
753
|
+
...ts.sys,
|
|
754
|
+
onUnRecoverableConfigFileDiagnostic: () => {}
|
|
755
|
+
});
|
|
756
|
+
if (!parsed) throw new Error(`can't read ${configPath}`);
|
|
757
|
+
return parsed;
|
|
758
|
+
};
|
|
759
|
+
let config = parseConfig();
|
|
760
|
+
const virtual = virtualPath(configPath, config.fileNames);
|
|
761
|
+
let text = "";
|
|
762
|
+
/** Bumped when a file changes, so the language service reads it again. */
|
|
763
|
+
const versions = /* @__PURE__ */ new Map();
|
|
764
|
+
const version = (path) => versions.get(path) ?? 0;
|
|
765
|
+
let fileNames = [...config.fileNames, virtual];
|
|
766
|
+
const read = (path) => samePath(path, virtual) ? text : ts.sys.readFile(path);
|
|
767
|
+
const service = ts.createLanguageService({
|
|
768
|
+
getCompilationSettings: () => config.options,
|
|
769
|
+
getScriptFileNames: () => fileNames,
|
|
770
|
+
getScriptVersion: (path) => String(version(path)),
|
|
771
|
+
getScriptSnapshot: (path) => {
|
|
772
|
+
const content = read(path);
|
|
773
|
+
return content === void 0 ? void 0 : ts.ScriptSnapshot.fromString(content);
|
|
774
|
+
},
|
|
775
|
+
getCurrentDirectory: () => dirname(configPath),
|
|
776
|
+
getDefaultLibFileName: (options) => ts.getDefaultLibFilePath(options),
|
|
777
|
+
fileExists: (path) => samePath(path, virtual) || ts.sys.fileExists(path),
|
|
778
|
+
readFile: read,
|
|
779
|
+
readDirectory: (...args) => ts.sys.readDirectory(...args),
|
|
780
|
+
directoryExists: (path) => ts.sys.directoryExists(path),
|
|
781
|
+
getDirectories: (path) => ts.sys.getDirectories(path)
|
|
782
|
+
}, ts.createDocumentRegistry());
|
|
783
|
+
return {
|
|
784
|
+
query(source, use) {
|
|
785
|
+
if (source !== text) {
|
|
786
|
+
text = source;
|
|
787
|
+
versions.set(virtual, version(virtual) + 1);
|
|
788
|
+
}
|
|
789
|
+
const program = service.getProgram();
|
|
790
|
+
const file = program?.getSourceFile(virtual);
|
|
791
|
+
if (!program || !file) return null;
|
|
792
|
+
const checker = program.getTypeChecker();
|
|
793
|
+
const typeOf = (alias) => {
|
|
794
|
+
const declaration = file.statements.find((statement) => ts.isTypeAliasDeclaration(statement) && statement.name.text === alias);
|
|
795
|
+
return declaration ? checker.getTypeAtLocation(declaration.name) : void 0;
|
|
796
|
+
};
|
|
797
|
+
return use({
|
|
798
|
+
describe: (alias) => {
|
|
799
|
+
const type = typeOf(alias);
|
|
800
|
+
return type ? checker.typeToString(type, void 0, ts.TypeFormatFlags.NoTruncation) : null;
|
|
801
|
+
},
|
|
802
|
+
members: (alias) => {
|
|
803
|
+
const type = typeOf(alias);
|
|
804
|
+
if (!type) return [];
|
|
805
|
+
return checker.getPropertiesOfType(type).map((symbol) => {
|
|
806
|
+
const node = symbol.declarations?.[0];
|
|
807
|
+
const declaredIn = node?.getSourceFile();
|
|
808
|
+
return {
|
|
809
|
+
name: symbol.name,
|
|
810
|
+
optional: (symbol.flags & ts.SymbolFlags.Optional) !== 0,
|
|
811
|
+
type: checker.typeToString(checker.getTypeOfSymbolAtLocation(symbol, file), void 0, ts.TypeFormatFlags.NoTruncation),
|
|
812
|
+
docs: ts.displayPartsToString(symbol.getDocumentationComment(checker)),
|
|
813
|
+
tags: symbol.getJsDocTags(checker).map((tag) => ({
|
|
814
|
+
name: tag.name,
|
|
815
|
+
text: ts.displayPartsToString(tag.text)
|
|
816
|
+
})),
|
|
817
|
+
location: node && declaredIn ? {
|
|
818
|
+
path: declaredIn.fileName,
|
|
819
|
+
...declaredIn.getLineAndCharacterOfPosition(node.getStart(declaredIn))
|
|
820
|
+
} : null
|
|
821
|
+
};
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
},
|
|
826
|
+
changed(changes) {
|
|
827
|
+
for (const change of changes) versions.set(change.path, version(change.path) + 1);
|
|
828
|
+
if (changes.some((change) => change.kind !== "changed")) {
|
|
829
|
+
config = parseConfig();
|
|
830
|
+
fileNames = [...config.fileNames, virtual];
|
|
831
|
+
}
|
|
832
|
+
},
|
|
833
|
+
close() {
|
|
834
|
+
service.dispose();
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
};
|
|
838
|
+
//#endregion
|
|
839
|
+
//#region src/typescript-7.ts
|
|
840
|
+
/** `TypeFormatFlags.NoTruncation`, so long types are written in full. */
|
|
841
|
+
const NO_TRUNCATION = 1;
|
|
842
|
+
/** A project checked by TypeScript 7's API, which runs the native compiler in its own process. */
|
|
843
|
+
const openTypeScript7 = (sync, configPath) => {
|
|
844
|
+
let virtual = "";
|
|
845
|
+
let text = "";
|
|
846
|
+
const api = new sync.API({
|
|
847
|
+
cwd: dirname(configPath),
|
|
848
|
+
fs: {
|
|
849
|
+
readFile: (path) => samePath(path, virtual) ? text : void 0,
|
|
850
|
+
fileExists: (path) => samePath(path, virtual) ? true : void 0,
|
|
851
|
+
getAccessibleEntries: (folder) => virtual && samePath(folder, dirname(virtual)) ? entriesWith(folder, basename(virtual)) : void 0
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
virtual = virtualPath(configPath, api.parseConfigFile(configPath).fileNames);
|
|
855
|
+
let snapshot = api.updateSnapshot({ openProjects: [configPath] });
|
|
856
|
+
const update = (changes) => {
|
|
857
|
+
snapshot.dispose();
|
|
858
|
+
snapshot = api.updateSnapshot(changes);
|
|
859
|
+
};
|
|
860
|
+
return {
|
|
861
|
+
query(source, use) {
|
|
862
|
+
if (source !== text) {
|
|
863
|
+
text = source;
|
|
864
|
+
update({ fileChanges: { changed: [virtual] } });
|
|
865
|
+
}
|
|
866
|
+
const project = snapshot.getProject(configPath);
|
|
867
|
+
if (!project?.program.getSourceFile(virtual)) return null;
|
|
868
|
+
const { checker } = project;
|
|
869
|
+
const typeOf = (alias) => {
|
|
870
|
+
const at = text.indexOf(`type ${alias} =`);
|
|
871
|
+
return at === -1 ? void 0 : checker.getTypeAtPosition(virtual, at + 5);
|
|
872
|
+
};
|
|
873
|
+
return use({
|
|
874
|
+
describe: (alias) => {
|
|
875
|
+
const type = typeOf(alias);
|
|
876
|
+
return type ? checker.typeToString(type, void 0, NO_TRUNCATION) : null;
|
|
877
|
+
},
|
|
878
|
+
members: (alias) => {
|
|
879
|
+
const type = typeOf(alias);
|
|
880
|
+
if (!type) return [];
|
|
881
|
+
return checker.getPropertiesOfType(type).map((symbol) => {
|
|
882
|
+
const node = symbol.declarations[0]?.resolve(project);
|
|
883
|
+
const file = node?.getSourceFile();
|
|
884
|
+
const memberType = checker.getTypeOfSymbol(symbol);
|
|
885
|
+
return {
|
|
886
|
+
name: symbol.name,
|
|
887
|
+
optional: (symbol.flags & sync.SymbolFlags.Optional) !== 0,
|
|
888
|
+
type: memberType ? checker.typeToString(memberType, void 0, NO_TRUNCATION) : "unknown",
|
|
889
|
+
docs: checker.getDocumentationCommentOfSymbol(symbol),
|
|
890
|
+
tags: checker.getJsDocTagsOfSymbol(symbol).map((tag) => ({
|
|
891
|
+
name: tag.name,
|
|
892
|
+
text: tag.text ?? ""
|
|
893
|
+
})),
|
|
894
|
+
location: node && file ? {
|
|
895
|
+
path: file.fileName,
|
|
896
|
+
...lineAndCharacter(file.text, node.getStart(file))
|
|
897
|
+
} : null
|
|
898
|
+
};
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
});
|
|
902
|
+
},
|
|
903
|
+
changed(changes) {
|
|
904
|
+
const paths = (kind) => changes.filter((change) => change.kind === kind).map((change) => change.path);
|
|
905
|
+
update({ fileChanges: {
|
|
906
|
+
created: paths("created"),
|
|
907
|
+
changed: paths("changed"),
|
|
908
|
+
deleted: paths("deleted")
|
|
909
|
+
} });
|
|
910
|
+
},
|
|
911
|
+
close() {
|
|
912
|
+
snapshot.dispose();
|
|
913
|
+
api.close();
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
};
|
|
917
|
+
//#endregion
|
|
918
|
+
//#region src/typescript.ts
|
|
919
|
+
/** The generated file's name. Nothing in the project should use it. */
|
|
920
|
+
const VIRTUAL_NAME = "__kiln_types__.ts";
|
|
921
|
+
/** Where the generated file goes: the folder of the project's first TypeScript source file, or the config's folder. */
|
|
922
|
+
const virtualPath = (configPath, fileNames) => {
|
|
923
|
+
const root = dirname(configPath);
|
|
924
|
+
const source = fileNames.find((name) => {
|
|
925
|
+
const path = relative(root, name);
|
|
926
|
+
return /\.[cm]?tsx?$/.test(name) && !/\.d\.[cm]?ts$/.test(name) && !path.startsWith("..") && !path.split(sep).includes("node_modules");
|
|
927
|
+
});
|
|
928
|
+
return join(source ? dirname(source) : root, VIRTUAL_NAME);
|
|
929
|
+
};
|
|
930
|
+
/** Whether two paths are the same file. TypeScript may change a path's case on case-insensitive file systems. */
|
|
931
|
+
const samePath = (a, b) => a === b || a.toLowerCase() === b.toLowerCase();
|
|
932
|
+
/** A folder's entries with the generated file added, for TypeScript's include globs. */
|
|
933
|
+
const entriesWith = (folder, name) => {
|
|
934
|
+
const entries = readdirSync(folder, { withFileTypes: true });
|
|
935
|
+
return {
|
|
936
|
+
files: [...entries.filter((entry) => entry.isFile()).map((entry) => entry.name), name],
|
|
937
|
+
directories: entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
|
|
938
|
+
};
|
|
939
|
+
};
|
|
940
|
+
/** The zero-based line and character of an offset in `text`. */
|
|
941
|
+
const lineAndCharacter = (text, offset) => {
|
|
942
|
+
const lines = text.slice(0, offset).split("\n");
|
|
943
|
+
return {
|
|
944
|
+
line: lines.length - 1,
|
|
945
|
+
character: lines.at(-1).length
|
|
946
|
+
};
|
|
947
|
+
};
|
|
948
|
+
/** The nearest `tsconfig.json` in `folder` or a folder above it. */
|
|
949
|
+
const findConfig = (folder) => {
|
|
950
|
+
for (let current = folder;; current = dirname(current)) {
|
|
951
|
+
const candidate = join(current, "tsconfig.json");
|
|
952
|
+
try {
|
|
953
|
+
readFileSync(candidate);
|
|
954
|
+
return candidate;
|
|
955
|
+
} catch {}
|
|
956
|
+
if (dirname(current) === current) return null;
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
/**
|
|
960
|
+
* Loads the TypeScript installed for the project in `folder`, with the nearest `tsconfig.json`. Null when either is
|
|
961
|
+
* missing or the version is older than 6. Version 7 and later use TypeScript's new API; version 6 uses the classic one.
|
|
962
|
+
*/
|
|
963
|
+
const openTypeScript = async (folder, warn) => {
|
|
964
|
+
const configPath = findConfig(folder);
|
|
965
|
+
if (!configPath) return null;
|
|
966
|
+
const require = createRequire(join(folder, "kiln.json"));
|
|
967
|
+
let major;
|
|
968
|
+
try {
|
|
969
|
+
const pkg = JSON.parse(readFileSync(require.resolve("typescript/package.json"), "utf8"));
|
|
970
|
+
major = Number(pkg.version.split(".")[0]);
|
|
971
|
+
} catch {
|
|
972
|
+
return null;
|
|
973
|
+
}
|
|
974
|
+
try {
|
|
975
|
+
if (major >= 7) {
|
|
976
|
+
const sync = await import(pathToFileURL(require.resolve("typescript/unstable/sync")).href);
|
|
977
|
+
return openTypeScript7(sync, configPath);
|
|
978
|
+
}
|
|
979
|
+
if (major === 6) return openTypeScript6(require("typescript"), configPath);
|
|
980
|
+
} catch (error) {
|
|
981
|
+
warn(`Kiln: couldn't load TypeScript. ${error.message}`);
|
|
982
|
+
}
|
|
983
|
+
return null;
|
|
984
|
+
};
|
|
985
|
+
//#endregion
|
|
986
|
+
//#region src/project.ts
|
|
987
|
+
/** Diagnostics that need to know the project's names and templates, so they are dropped without a `kiln.json`. */
|
|
988
|
+
const NEEDS_MANIFEST = /* @__PURE__ */ new Set([
|
|
989
|
+
"unknown-function",
|
|
990
|
+
"unknown-filter",
|
|
991
|
+
"unknown-test",
|
|
992
|
+
"unknown-type",
|
|
993
|
+
"missing-template"
|
|
994
|
+
]);
|
|
995
|
+
/**
|
|
996
|
+
* Resolves a template name as Kiln does: relative to `from` when it starts with `./` or `../`, with `.kiln` added when the last part has no extension. Null when the name is invalid.
|
|
997
|
+
*/
|
|
998
|
+
const resolveName = (name, from) => {
|
|
999
|
+
const segments = (name.startsWith("./") || name.startsWith("../")) && from ? from.split("/").slice(0, -1) : [];
|
|
1000
|
+
for (const segment of name.split("/")) {
|
|
1001
|
+
if (segment === "" || segment === ".") continue;
|
|
1002
|
+
if (segment === "..") {
|
|
1003
|
+
if (segments.length === 0) return null;
|
|
1004
|
+
segments.pop();
|
|
1005
|
+
} else segments.push(segment);
|
|
1006
|
+
}
|
|
1007
|
+
const last = segments.at(-1);
|
|
1008
|
+
if (last === void 0 || name.startsWith("/")) return null;
|
|
1009
|
+
if (!last.includes(".")) segments[segments.length - 1] = `${last}.kiln`;
|
|
1010
|
+
return segments.join("/");
|
|
1011
|
+
};
|
|
1012
|
+
/** The template name for a file, or null when it is outside the project's root. */
|
|
1013
|
+
const nameOf = (project, path) => {
|
|
1014
|
+
const name = relative(project.root, path);
|
|
1015
|
+
if (name.startsWith("..") || name.startsWith(sep) || name === "") return null;
|
|
1016
|
+
return name.split(sep).join("/");
|
|
1017
|
+
};
|
|
1018
|
+
const pathOf = (project, name) => join(project.root, ...name.split("/"));
|
|
1019
|
+
/** Every template under the root, skipping dot folders and node_modules. */
|
|
1020
|
+
const listTemplates = async (project) => {
|
|
1021
|
+
const names = [];
|
|
1022
|
+
const visit = async (folder, from) => {
|
|
1023
|
+
const entries = await readdir(folder, { withFileTypes: true }).catch(() => []);
|
|
1024
|
+
for (const entry of entries) {
|
|
1025
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
1026
|
+
const name = from + entry.name;
|
|
1027
|
+
if (entry.isDirectory()) await visit(join(folder, entry.name), name + "/");
|
|
1028
|
+
else if (entry.isFile() && name.endsWith(".kiln")) names.push(name);
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
await visit(project.root, "");
|
|
1032
|
+
return names.sort();
|
|
1033
|
+
};
|
|
1034
|
+
/** The nearest `kiln.json` in `folder` or above it, stopping at `stop` when given. */
|
|
1035
|
+
const findManifest = async (folder, stop) => {
|
|
1036
|
+
for (let current = folder;; current = dirname(current)) {
|
|
1037
|
+
const candidate = join(current, "kiln.json");
|
|
1038
|
+
try {
|
|
1039
|
+
await readFile(candidate, "utf8");
|
|
1040
|
+
return candidate;
|
|
1041
|
+
} catch {}
|
|
1042
|
+
if (current === stop || dirname(current) === current) return null;
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
/** Reads `kiln.json`, filling in defaults. Throws when it isn't valid. */
|
|
1046
|
+
const readManifest = async (path) => {
|
|
1047
|
+
const data = JSON.parse(await readFile(path, "utf8"));
|
|
1048
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) throw new Error("kiln.json must hold an object");
|
|
1049
|
+
const field = (key) => {
|
|
1050
|
+
const value = data[key];
|
|
1051
|
+
if (value === void 0) return [];
|
|
1052
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error(`"${key}" in kiln.json must be a list of names`);
|
|
1053
|
+
return value;
|
|
1054
|
+
};
|
|
1055
|
+
const { root = ".", strict = true, htmlChecks = true } = data;
|
|
1056
|
+
if (typeof root !== "string") throw new Error("\"root\" in kiln.json must be a string");
|
|
1057
|
+
if (typeof strict !== "boolean") throw new Error("\"strict\" in kiln.json must be true or false");
|
|
1058
|
+
if (typeof htmlChecks !== "boolean" && (typeof htmlChecks !== "object" || htmlChecks === null || Array.isArray(htmlChecks))) throw new Error("\"htmlChecks\" in kiln.json must be true, false or an object");
|
|
1059
|
+
return {
|
|
1060
|
+
root,
|
|
1061
|
+
strict,
|
|
1062
|
+
htmlChecks,
|
|
1063
|
+
functions: field("functions"),
|
|
1064
|
+
filters: field("filters"),
|
|
1065
|
+
tests: field("tests"),
|
|
1066
|
+
globals: field("globals"),
|
|
1067
|
+
types: field("types")
|
|
1068
|
+
};
|
|
1069
|
+
};
|
|
1070
|
+
/** Imports an entry point of the copy of Kiln installed in a project, or returns null. */
|
|
1071
|
+
const importInstalled = async (from, entry) => {
|
|
1072
|
+
for (let current = from;; current = dirname(current)) {
|
|
1073
|
+
const folder = join(current, "node_modules", "@lucidkiln", "core");
|
|
1074
|
+
try {
|
|
1075
|
+
const file = JSON.parse(await readFile(join(folder, "package.json"), "utf8")).exports?.[entry]?.import;
|
|
1076
|
+
return file ? await import(pathToFileURL(join(folder, file)).href) : null;
|
|
1077
|
+
} catch {}
|
|
1078
|
+
if (dirname(current) === current) return null;
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
const stubs = (names, value) => Object.fromEntries(names.map((name) => [name, value]));
|
|
1082
|
+
/**
|
|
1083
|
+
* Opens the project for a template folder: the nearest `kiln.json`, or `fallbackRoot` without one. Prefers the project's own copy of Kiln, so errors match how it renders.
|
|
1084
|
+
*
|
|
1085
|
+
* A broken `kiln.json` is reported through `warn`, and the project is opened as if it had none.
|
|
1086
|
+
*/
|
|
1087
|
+
const openProject = async (manifestPath, fallbackRoot, warn) => {
|
|
1088
|
+
let manifest = null;
|
|
1089
|
+
if (manifestPath) try {
|
|
1090
|
+
manifest = await readManifest(manifestPath);
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
warn(`Kiln: ignoring ${manifestPath}. ${error.message}`);
|
|
1093
|
+
}
|
|
1094
|
+
const root = manifestPath && manifest ? join(dirname(manifestPath), manifest.root) : fallbackRoot;
|
|
1095
|
+
const base = manifestPath ? dirname(manifestPath) : fallbackRoot;
|
|
1096
|
+
const { createKiln } = await importInstalled(base, ".") ?? bundledKiln;
|
|
1097
|
+
const format = await importInstalled(base, "./format") ?? bundledFormat;
|
|
1098
|
+
const overlay = /* @__PURE__ */ new Map();
|
|
1099
|
+
const options = { loader: { load: async (name) => {
|
|
1100
|
+
const open = overlay.get(name);
|
|
1101
|
+
if (open !== void 0) return open;
|
|
1102
|
+
if (!manifest) return null;
|
|
1103
|
+
try {
|
|
1104
|
+
return await readFile(join(root, ...name.split("/")), "utf8");
|
|
1105
|
+
} catch {
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
} } };
|
|
1109
|
+
let kiln;
|
|
1110
|
+
try {
|
|
1111
|
+
kiln = manifest ? createKiln({
|
|
1112
|
+
...options,
|
|
1113
|
+
functions: stubs(manifest.functions, () => null),
|
|
1114
|
+
filters: stubs(manifest.filters, () => null),
|
|
1115
|
+
tests: stubs(manifest.tests, () => true),
|
|
1116
|
+
globals: stubs(manifest.globals, null),
|
|
1117
|
+
types: stubs(manifest.types, () => true),
|
|
1118
|
+
strict: manifest.strict,
|
|
1119
|
+
htmlChecks: manifest.htmlChecks
|
|
1120
|
+
}) : createKiln(options);
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
warn(`Kiln: ignoring ${manifestPath}. ${error.message}`);
|
|
1123
|
+
manifest = null;
|
|
1124
|
+
kiln = createKiln(options);
|
|
1125
|
+
}
|
|
1126
|
+
let typescript;
|
|
1127
|
+
const folder = manifestPath && manifest ? dirname(manifestPath) : null;
|
|
1128
|
+
return {
|
|
1129
|
+
manifestPath,
|
|
1130
|
+
manifest,
|
|
1131
|
+
root,
|
|
1132
|
+
kiln,
|
|
1133
|
+
format,
|
|
1134
|
+
overlay,
|
|
1135
|
+
typescript: () => typescript ??= folder ? openTypeScript(folder, warn) : Promise.resolve(null),
|
|
1136
|
+
typescriptChanged: (changes) => void typescript?.then((opened) => opened?.changed(changes)),
|
|
1137
|
+
close: () => void typescript?.then((opened) => opened?.close())
|
|
1138
|
+
};
|
|
1139
|
+
};
|
|
1140
|
+
/** Checks a template's text and returns its own diagnostics. */
|
|
1141
|
+
const diagnose = async (project, name, text) => {
|
|
1142
|
+
return (await project.kiln.check(name, text)).filter((diagnostic) => diagnostic.template === name && (project.manifest !== null || !NEEDS_MANIFEST.has(diagnostic.code)));
|
|
1143
|
+
};
|
|
1144
|
+
//#endregion
|
|
1145
|
+
//#region src/scopes.ts
|
|
1146
|
+
/** Whether a variable can be used at `offset`. A scope's end is exclusive, except at the end of the template. */
|
|
1147
|
+
const sees = (binding, offset, length) => offset >= binding.from && (offset < binding.scope.end || binding.scope.end === length);
|
|
1148
|
+
/** The offset of `name` as a whole word in `text`, searching `span` from `after`. Falls back to the span's start. */
|
|
1149
|
+
const nameIn = (text, span, name, after = span.start) => {
|
|
1150
|
+
const pattern = new RegExp(`(?<![\\w$])${name}(?![\\w$])`, "g");
|
|
1151
|
+
pattern.lastIndex = after;
|
|
1152
|
+
const match = pattern.exec(text.slice(0, span.end));
|
|
1153
|
+
const start = match ? match.index : span.start;
|
|
1154
|
+
return {
|
|
1155
|
+
start,
|
|
1156
|
+
end: start + name.length
|
|
1157
|
+
};
|
|
1158
|
+
};
|
|
1159
|
+
/** The expressions directly inside an expression. */
|
|
1160
|
+
const childExpressions = (expression) => {
|
|
1161
|
+
const ofArgs = (args) => args.map((arg) => arg.value);
|
|
1162
|
+
switch (expression.type) {
|
|
1163
|
+
case "Interpolation": return expression.parts;
|
|
1164
|
+
case "Array": return expression.elements.map((element) => element.type === "Spread" ? element.value : element);
|
|
1165
|
+
case "Hash": return expression.entries.flatMap((entry) => entry.type === "Spread" ? [entry.value] : [entry.key, entry.value]);
|
|
1166
|
+
case "Unary": return [expression.operand];
|
|
1167
|
+
case "Binary": return [expression.left, expression.right];
|
|
1168
|
+
case "Conditional": return [
|
|
1169
|
+
expression.test,
|
|
1170
|
+
expression.consequent,
|
|
1171
|
+
...expression.alternate ? [expression.alternate] : []
|
|
1172
|
+
];
|
|
1173
|
+
case "Member": return [expression.object, expression.property];
|
|
1174
|
+
case "Call": return [expression.callee, ...ofArgs(expression.args)];
|
|
1175
|
+
case "Filter":
|
|
1176
|
+
case "Test": return [expression.input, ...ofArgs(expression.args)];
|
|
1177
|
+
case "Arrow": return [expression.body];
|
|
1178
|
+
case "OptionalChain": return [expression.expression];
|
|
1179
|
+
default: return [];
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
/** The expressions a statement holds itself, not those in nested statements. */
|
|
1183
|
+
const statementExpressions = (statement) => {
|
|
1184
|
+
switch (statement.type) {
|
|
1185
|
+
case "Output": return [statement.expression];
|
|
1186
|
+
case "If": return statement.branches.map((branch) => branch.test);
|
|
1187
|
+
case "For": return [statement.iterable];
|
|
1188
|
+
case "Set": return [statement.value];
|
|
1189
|
+
case "Props": return statement.props.flatMap((prop) => prop.default ? [prop.default] : []);
|
|
1190
|
+
case "Extends": return [statement.template];
|
|
1191
|
+
case "Include":
|
|
1192
|
+
case "Component":
|
|
1193
|
+
case "Island": return [statement.template, ...statement.with ? [statement.with] : []];
|
|
1194
|
+
case "Macro": return statement.params.flatMap((param) => param.default ? [param.default] : []);
|
|
1195
|
+
case "With": return [statement.value];
|
|
1196
|
+
case "Cache": return [statement.key, ...statement.seconds ? [statement.seconds] : []];
|
|
1197
|
+
case "Apply": return statement.filters.flatMap((filter) => filter.args.map((arg) => arg.value));
|
|
1198
|
+
default: return [];
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
/** The statement lists inside a statement, and whether each starts a new scope. */
|
|
1202
|
+
const childBodies = (statement) => {
|
|
1203
|
+
switch (statement.type) {
|
|
1204
|
+
case "If": return [...statement.branches.map((branch) => ({
|
|
1205
|
+
body: branch.body,
|
|
1206
|
+
scoped: false
|
|
1207
|
+
})), ...statement.alternate ? [{
|
|
1208
|
+
body: statement.alternate,
|
|
1209
|
+
scoped: false
|
|
1210
|
+
}] : []];
|
|
1211
|
+
case "For": return [{
|
|
1212
|
+
body: statement.body,
|
|
1213
|
+
scoped: true
|
|
1214
|
+
}, ...statement.alternate ? [{
|
|
1215
|
+
body: statement.alternate,
|
|
1216
|
+
scoped: false
|
|
1217
|
+
}] : []];
|
|
1218
|
+
case "Component": return [{
|
|
1219
|
+
body: statement.body,
|
|
1220
|
+
scoped: true
|
|
1221
|
+
}, ...statement.fills.map((fill) => ({
|
|
1222
|
+
body: fill.body,
|
|
1223
|
+
scoped: true
|
|
1224
|
+
}))];
|
|
1225
|
+
case "SetBlock":
|
|
1226
|
+
case "Add":
|
|
1227
|
+
case "Cache":
|
|
1228
|
+
case "Apply":
|
|
1229
|
+
case "Autoescape":
|
|
1230
|
+
case "Island":
|
|
1231
|
+
case "Slot": return [{
|
|
1232
|
+
body: statement.body,
|
|
1233
|
+
scoped: false
|
|
1234
|
+
}];
|
|
1235
|
+
case "Block":
|
|
1236
|
+
case "Macro":
|
|
1237
|
+
case "With": return [{
|
|
1238
|
+
body: statement.body,
|
|
1239
|
+
scoped: true
|
|
1240
|
+
}];
|
|
1241
|
+
default: return [];
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
/** Every expression in a list of statements, including nested ones, outermost first. */
|
|
1245
|
+
function* expressionsIn(statements) {
|
|
1246
|
+
const visit = function* (expression) {
|
|
1247
|
+
yield expression;
|
|
1248
|
+
for (const child of childExpressions(expression)) yield* visit(child);
|
|
1249
|
+
};
|
|
1250
|
+
for (const statement of statements) {
|
|
1251
|
+
for (const expression of statementExpressions(statement)) yield* visit(expression);
|
|
1252
|
+
for (const { body } of childBodies(statement)) yield* expressionsIn(body);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
/** The innermost expression whose span holds `offset`, and the expressions around it, outermost first. */
|
|
1256
|
+
const expressionPathAt = (ast, offset) => {
|
|
1257
|
+
const path = [];
|
|
1258
|
+
const visit = (expression) => {
|
|
1259
|
+
if (offset < expression.span.start || offset > expression.span.end) return false;
|
|
1260
|
+
path.push(expression);
|
|
1261
|
+
for (const child of childExpressions(expression)) if (visit(child)) return true;
|
|
1262
|
+
return true;
|
|
1263
|
+
};
|
|
1264
|
+
for (const expression of expressionsIn(ast.body)) if (visit(expression)) break;
|
|
1265
|
+
return path;
|
|
1266
|
+
};
|
|
1267
|
+
/**
|
|
1268
|
+
* Parses a template and finds every variable it brings into scope, following Kiln's rules: `for`, `with`, `block`,
|
|
1269
|
+
* `macro`, `component` and `fill` start a scope, and `set` updates a variable that is already visible or creates one in
|
|
1270
|
+
* the current scope.
|
|
1271
|
+
*/
|
|
1272
|
+
const templateScopes = (text, isTest) => {
|
|
1273
|
+
const { ast } = parse(text, { isTest });
|
|
1274
|
+
const bindings = [];
|
|
1275
|
+
const visible = (name, offset) => bindings.some((binding) => binding.name === name && sees(binding, offset, text.length));
|
|
1276
|
+
const arrows = (expression) => {
|
|
1277
|
+
for (const node of expressionsIn([{
|
|
1278
|
+
type: "Output",
|
|
1279
|
+
expression,
|
|
1280
|
+
span: expression.span
|
|
1281
|
+
}])) {
|
|
1282
|
+
if (node.type !== "Filter") continue;
|
|
1283
|
+
for (const arg of node.args) {
|
|
1284
|
+
if (arg.value.type !== "Arrow") continue;
|
|
1285
|
+
const arrow = arg.value;
|
|
1286
|
+
const head = text.slice(arrow.span.start, arrow.body.span.start);
|
|
1287
|
+
arrow.params.forEach((name, index) => {
|
|
1288
|
+
const at = nameIn(head, {
|
|
1289
|
+
start: 0,
|
|
1290
|
+
end: head.length
|
|
1291
|
+
}, name);
|
|
1292
|
+
bindings.push({
|
|
1293
|
+
name,
|
|
1294
|
+
source: {
|
|
1295
|
+
kind: "arrow",
|
|
1296
|
+
filter: node.name,
|
|
1297
|
+
input: node.input,
|
|
1298
|
+
index
|
|
1299
|
+
},
|
|
1300
|
+
definition: {
|
|
1301
|
+
start: arrow.span.start + at.start,
|
|
1302
|
+
end: arrow.span.start + at.end
|
|
1303
|
+
},
|
|
1304
|
+
scope: arrow.span,
|
|
1305
|
+
from: arrow.span.start
|
|
1306
|
+
});
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
const walk = (statements, scope) => {
|
|
1312
|
+
for (const statement of statements) {
|
|
1313
|
+
for (const expression of statementExpressions(statement)) arrows(expression);
|
|
1314
|
+
const add = (name, source, definition, inner = scope, from = statement.span.end) => bindings.push({
|
|
1315
|
+
name,
|
|
1316
|
+
source,
|
|
1317
|
+
definition,
|
|
1318
|
+
scope: inner,
|
|
1319
|
+
from
|
|
1320
|
+
});
|
|
1321
|
+
switch (statement.type) {
|
|
1322
|
+
case "Props":
|
|
1323
|
+
for (const prop of statement.props) add(prop.name, {
|
|
1324
|
+
kind: "prop",
|
|
1325
|
+
type: prop.type,
|
|
1326
|
+
optional: prop.default !== null
|
|
1327
|
+
}, {
|
|
1328
|
+
start: prop.span.start,
|
|
1329
|
+
end: prop.span.start + prop.name.length
|
|
1330
|
+
}, scope, scope.start);
|
|
1331
|
+
if (statement.rest) {
|
|
1332
|
+
const rest = statement.rest;
|
|
1333
|
+
add(rest, { kind: "rest" }, nameIn(text, statement.span, rest), scope, scope.start);
|
|
1334
|
+
}
|
|
1335
|
+
break;
|
|
1336
|
+
case "Set":
|
|
1337
|
+
case "SetBlock":
|
|
1338
|
+
if (!visible(statement.name, statement.span.start)) {
|
|
1339
|
+
const after = text.indexOf("set", statement.span.start) + 3;
|
|
1340
|
+
add(statement.name, statement.type === "Set" ? {
|
|
1341
|
+
kind: "set",
|
|
1342
|
+
value: statement.value
|
|
1343
|
+
} : { kind: "capture" }, nameIn(text, statement.span, statement.name, after));
|
|
1344
|
+
}
|
|
1345
|
+
break;
|
|
1346
|
+
case "For": {
|
|
1347
|
+
const after = text.indexOf("for", statement.span.start) + 3;
|
|
1348
|
+
const from = statement.iterable.span.end;
|
|
1349
|
+
if (statement.keyName) add(statement.keyName, {
|
|
1350
|
+
kind: "key",
|
|
1351
|
+
iterable: statement.iterable
|
|
1352
|
+
}, nameIn(text, statement.span, statement.keyName, after), statement.span, from);
|
|
1353
|
+
add(statement.valueName, {
|
|
1354
|
+
kind: "item",
|
|
1355
|
+
iterable: statement.iterable
|
|
1356
|
+
}, nameIn(text, statement.span, statement.valueName, statement.keyName ? nameIn(text, statement.span, statement.keyName, after).end : after), statement.span, from);
|
|
1357
|
+
break;
|
|
1358
|
+
}
|
|
1359
|
+
case "Macro":
|
|
1360
|
+
for (const param of statement.params) add(param.name, { kind: "param" }, {
|
|
1361
|
+
start: param.span.start,
|
|
1362
|
+
end: param.span.start + param.name.length
|
|
1363
|
+
}, statement.span, param.span.end);
|
|
1364
|
+
break;
|
|
1365
|
+
case "With":
|
|
1366
|
+
if (statement.value.type === "Hash") for (const entry of statement.value.entries) {
|
|
1367
|
+
if (entry.type !== "Property" || entry.key.type !== "Literal" || typeof entry.key.value !== "string") continue;
|
|
1368
|
+
add(entry.key.value, {
|
|
1369
|
+
kind: "with",
|
|
1370
|
+
value: statement.value,
|
|
1371
|
+
key: entry.key.value
|
|
1372
|
+
}, entry.key.span, statement.span, statement.value.span.end);
|
|
1373
|
+
}
|
|
1374
|
+
break;
|
|
1375
|
+
case "Import": {
|
|
1376
|
+
const after = text.lastIndexOf(" as ", statement.span.end);
|
|
1377
|
+
add(statement.alias, { kind: "import" }, nameIn(text, statement.span, statement.alias, Math.max(after, 0)));
|
|
1378
|
+
break;
|
|
1379
|
+
}
|
|
1380
|
+
case "FromImport": for (const item of statement.names) add(item.alias, { kind: "import" }, nameIn(text, item.span, item.alias, item.span.start));
|
|
1381
|
+
}
|
|
1382
|
+
for (const { body, scoped } of childBodies(statement)) walk(body, scoped ? statement.span : scope);
|
|
1383
|
+
}
|
|
1384
|
+
};
|
|
1385
|
+
walk(ast.body, ast.span);
|
|
1386
|
+
return {
|
|
1387
|
+
ast,
|
|
1388
|
+
bindings
|
|
1389
|
+
};
|
|
1390
|
+
};
|
|
1391
|
+
/** The variable `name` refers to at `offset`: the one in the innermost scope that can see it there. */
|
|
1392
|
+
const bindingAt = (scopes, name, offset) => {
|
|
1393
|
+
let found = null;
|
|
1394
|
+
for (const binding of scopes.bindings) {
|
|
1395
|
+
if (binding.name !== name || !sees(binding, offset, scopes.ast.span.end)) continue;
|
|
1396
|
+
if (!found || binding.scope.end - binding.scope.start < found.scope.end - found.scope.start) found = binding;
|
|
1397
|
+
}
|
|
1398
|
+
return found;
|
|
1399
|
+
};
|
|
1400
|
+
/** Every variable visible at `offset`, innermost first, one per name. */
|
|
1401
|
+
const bindingsAt = (scopes, offset) => {
|
|
1402
|
+
return [...new Set(scopes.bindings.map((binding) => binding.name))].flatMap((name) => {
|
|
1403
|
+
const binding = bindingAt(scopes, name, offset);
|
|
1404
|
+
return binding ? [binding] : [];
|
|
1405
|
+
});
|
|
1406
|
+
};
|
|
1407
|
+
//#endregion
|
|
1408
|
+
//#region src/types.ts
|
|
1409
|
+
/**
|
|
1410
|
+
* Kiln's rules for values, written as TypeScript helper types. Each Kiln expression becomes a type built from these,
|
|
1411
|
+
* and the project's own TypeScript works out what it is.
|
|
1412
|
+
*
|
|
1413
|
+
* - `Get`: `a.b` and `a[0]`, reading a property, a map's value or a list's item.
|
|
1414
|
+
* - `Item`, `Key`: what `for` gives for each pass.
|
|
1415
|
+
* - `Result`: what a call gives once Kiln waits for it. `Params` and `Returns` describe the function for hover.
|
|
1416
|
+
* - `Members`: the values whose properties a template can read, so lists, maps and primitives complete nothing.
|
|
1417
|
+
* - `Last`: a function's last parameter, which named arguments are gathered into.
|
|
1418
|
+
* - `Fits`, `NamedFit`: whether an argument fits its parameter. Unknown types always fit, so the editor never guesses.
|
|
1419
|
+
*/
|
|
1420
|
+
const PRELUDE = `import type { KilnFilters, KilnFunctions, KilnGlobals, KilnTests, KilnTypes, Markup, RenderAware } from "@lucidkiln/core";
|
|
1421
|
+
type Named<N extends string> = N extends keyof KilnTypes ? KilnTypes[N] : unknown;
|
|
1422
|
+
type Get<V, K> = NonNullable<V> extends infer O ? O extends ReadonlyMap<unknown, infer M> ? M : O extends readonly (infer I)[] ? (K extends number ? I : unknown) : K extends keyof O ? O[K] : unknown : never;
|
|
1423
|
+
type Item<V> = NonNullable<V> extends infer O ? O extends string ? unknown : O extends readonly (infer I)[] ? I : O extends ReadonlyMap<unknown, infer M> ? M : O extends ReadonlySet<infer S> ? S : O extends Iterable<infer I> ? I : O extends object ? O[keyof O] : unknown : never;
|
|
1424
|
+
type Key<V> = NonNullable<V> extends infer O ? O extends string ? unknown : O extends readonly unknown[] | ReadonlySet<unknown> ? number : O extends ReadonlyMap<infer K, unknown> ? K : O extends object ? keyof O & string : unknown : never;
|
|
1425
|
+
type Returns<F> = F extends RenderAware<any, any, infer R> ? R : F extends (...args: any) => infer R ? R : unknown;
|
|
1426
|
+
type Result<F> = Awaited<Returns<F>>;
|
|
1427
|
+
type Params<F> = F extends RenderAware<any, infer A, any> ? A : F extends (...args: infer A) => any ? A : never;
|
|
1428
|
+
type Tail<A> = A extends readonly [unknown?, ...infer R] ? R : A;
|
|
1429
|
+
type Last<A> = Required<A> extends readonly [...unknown[], infer L] ? L : A extends readonly (infer E)[] ? E : unknown;
|
|
1430
|
+
type Fits<A, P> = [P] extends [never] ? true : unknown extends P ? true : unknown extends A ? true : [Exclude<A, Markup>] extends [P] ? true : false;
|
|
1431
|
+
type NamedFit<L, K, A> = unknown extends L ? "ok" : [L] extends [never] ? "ok" : NonNullable<L> extends infer O ? string extends keyof O ? "ok" : K extends keyof O ? Fits<A, Exclude<O[K], undefined>> extends true ? "ok" : "mismatch" : "missing" : "ok";
|
|
1432
|
+
type Members<V> = NonNullable<V> extends infer O ? O extends string | number | boolean | bigint | symbol | Function | Date | Markup | readonly unknown[] | ReadonlyMap<unknown, unknown> | ReadonlySet<unknown> ? never : O : never;
|
|
1433
|
+
`;
|
|
1434
|
+
const BUILTIN_TYPES = {
|
|
1435
|
+
null: "null",
|
|
1436
|
+
boolean: "boolean",
|
|
1437
|
+
number: "number",
|
|
1438
|
+
string: "string | Markup",
|
|
1439
|
+
array: "readonly unknown[]",
|
|
1440
|
+
hash: "Readonly<Record<string, unknown>> | ReadonlyMap<string, unknown>",
|
|
1441
|
+
set: "ReadonlySet<unknown>",
|
|
1442
|
+
date: "Date",
|
|
1443
|
+
markup: "Markup",
|
|
1444
|
+
object: "object"
|
|
1445
|
+
};
|
|
1446
|
+
/** A prop's declared type as TypeScript, the same way `generateTypes` writes it. */
|
|
1447
|
+
const propType = (type) => {
|
|
1448
|
+
if (type === null) return "unknown";
|
|
1449
|
+
if (type.length === 0) return "never";
|
|
1450
|
+
return type.map((member) => {
|
|
1451
|
+
if (typeof member === "string") return BUILTIN_TYPES[member] ?? "unknown";
|
|
1452
|
+
if ("literal" in member) return JSON.stringify(member.literal);
|
|
1453
|
+
if ("named" in member) return `Named<${JSON.stringify(member.named)}>`;
|
|
1454
|
+
if ("list" in member) return `readonly (${propType(member.list)})[]`;
|
|
1455
|
+
if (member.shape.length === 0) return "object";
|
|
1456
|
+
return `{ ${member.shape.map((key) => `${JSON.stringify(key.name)}${key.optional ? "?" : ""}: ${propType(key.type)}`).join("; ")} }`;
|
|
1457
|
+
}).join(" | ");
|
|
1458
|
+
};
|
|
1459
|
+
const BUILTIN_FUNCTIONS = {
|
|
1460
|
+
range: "number[]",
|
|
1461
|
+
max: "number",
|
|
1462
|
+
min: "number",
|
|
1463
|
+
cycle: "unknown",
|
|
1464
|
+
date: "Date",
|
|
1465
|
+
attrs: "Markup",
|
|
1466
|
+
dump: "Markup",
|
|
1467
|
+
parent: "Markup",
|
|
1468
|
+
block: "Markup"
|
|
1469
|
+
};
|
|
1470
|
+
/** What each built-in filter gives, from the type of the value before the `|` and the types of its arguments. */
|
|
1471
|
+
const BUILTIN_FILTERS = {
|
|
1472
|
+
upper: () => "string",
|
|
1473
|
+
lower: () => "string",
|
|
1474
|
+
capitalize: () => "string",
|
|
1475
|
+
title: () => "string",
|
|
1476
|
+
trim: () => "string",
|
|
1477
|
+
replace: () => "string",
|
|
1478
|
+
split: () => "string[]",
|
|
1479
|
+
truncate: () => "string",
|
|
1480
|
+
stripTags: () => "string",
|
|
1481
|
+
urlEncode: () => "string",
|
|
1482
|
+
json: () => "Markup",
|
|
1483
|
+
escape: () => "Markup",
|
|
1484
|
+
raw: () => "Markup",
|
|
1485
|
+
default: (input, [fallback = "string"]) => `NonNullable<${input}> | ${fallback}`,
|
|
1486
|
+
length: () => "number",
|
|
1487
|
+
first: (input) => `Item<${input}>`,
|
|
1488
|
+
last: (input) => `Item<${input}>`,
|
|
1489
|
+
slice: (input) => input,
|
|
1490
|
+
join: () => "string",
|
|
1491
|
+
keys: (input) => `Key<${input}>[]`,
|
|
1492
|
+
merge: (input) => input,
|
|
1493
|
+
reverse: (input) => input,
|
|
1494
|
+
sort: (input) => input,
|
|
1495
|
+
map: () => "unknown[]",
|
|
1496
|
+
filter: (input) => input,
|
|
1497
|
+
reduce: () => "unknown",
|
|
1498
|
+
find: (input) => `Item<${input}> | null`,
|
|
1499
|
+
column: () => "unknown[]",
|
|
1500
|
+
batch: (input) => `Item<${input}>[][]`,
|
|
1501
|
+
abs: () => "number",
|
|
1502
|
+
round: () => "number",
|
|
1503
|
+
formatNumber: () => "string",
|
|
1504
|
+
plural: () => "string",
|
|
1505
|
+
formatDate: () => "string"
|
|
1506
|
+
};
|
|
1507
|
+
/** The list item an arrow function's parameter gets, by filter: which parameter it is. */
|
|
1508
|
+
const ITEM_PARAMS = {
|
|
1509
|
+
map: [0],
|
|
1510
|
+
filter: [0],
|
|
1511
|
+
find: [0],
|
|
1512
|
+
sort: [0, 1],
|
|
1513
|
+
reduce: [1]
|
|
1514
|
+
};
|
|
1515
|
+
const MAX_DEPTH = 32;
|
|
1516
|
+
/** The TypeScript type of a variable, from where its value comes from. */
|
|
1517
|
+
const bindingType = (source, context, depth = 0) => {
|
|
1518
|
+
const of = (expression) => expressionType(expression, context, depth + 1);
|
|
1519
|
+
switch (source.kind) {
|
|
1520
|
+
case "prop": return propType(source.type);
|
|
1521
|
+
case "rest": return "Readonly<Record<string, unknown>>";
|
|
1522
|
+
case "set": return of(source.value);
|
|
1523
|
+
case "capture": return "Markup";
|
|
1524
|
+
case "item": return `Item<${of(source.iterable)}>`;
|
|
1525
|
+
case "key": return `Key<${of(source.iterable)}>`;
|
|
1526
|
+
case "arrow": return ITEM_PARAMS[source.filter]?.includes(source.index) ? `Item<${of(source.input)}>` : "unknown";
|
|
1527
|
+
case "with": return `Get<${of(source.value)}, ${JSON.stringify(source.key)}>`;
|
|
1528
|
+
default: return "unknown";
|
|
1529
|
+
}
|
|
1530
|
+
};
|
|
1531
|
+
const union = (types) => types.length === 0 ? "never" : [...new Set(types)].join(" | ");
|
|
1532
|
+
const literalKey = (expression) => expression.type === "Literal" && (typeof expression.value === "string" || typeof expression.value === "number") ? JSON.stringify(expression.value) : null;
|
|
1533
|
+
/** Writes the TypeScript type of a Kiln expression, using the helper types in `PRELUDE`. */
|
|
1534
|
+
const expressionType = (expression, context, depth = 0) => {
|
|
1535
|
+
if (depth > MAX_DEPTH) return "unknown";
|
|
1536
|
+
const of = (inner) => expressionType(inner, context, depth + 1);
|
|
1537
|
+
const args = (list) => list.map((arg) => arg.type === "Spread" ? "unknown" : of(arg.value));
|
|
1538
|
+
switch (expression.type) {
|
|
1539
|
+
case "Literal": return expression.value === null ? "null" : typeof expression.value;
|
|
1540
|
+
case "Interpolation": return "string";
|
|
1541
|
+
case "Array": return `(${union(expression.elements.map((element) => element.type === "Spread" ? `Item<${of(element.value)}>` : of(element)))})[]`;
|
|
1542
|
+
case "Hash": return `{ ${expression.entries.flatMap((entry) => {
|
|
1543
|
+
if (entry.type === "Spread") return [];
|
|
1544
|
+
const key = entry.key.type === "Name" ? JSON.stringify(entry.key.name) : literalKey(entry.key);
|
|
1545
|
+
return key ? [`${key}: ${of(entry.value)}`] : [];
|
|
1546
|
+
}).join("; ")} }`;
|
|
1547
|
+
case "Unary": return expression.operator === "not" ? "boolean" : "number";
|
|
1548
|
+
case "Binary": switch (expression.operator) {
|
|
1549
|
+
case "??":
|
|
1550
|
+
case "?:": return `NonNullable<${of(expression.left)}> | ${of(expression.right)}`;
|
|
1551
|
+
case "~": return "string";
|
|
1552
|
+
case "..": return "number[]";
|
|
1553
|
+
case "+":
|
|
1554
|
+
case "-":
|
|
1555
|
+
case "*":
|
|
1556
|
+
case "/":
|
|
1557
|
+
case "//":
|
|
1558
|
+
case "%":
|
|
1559
|
+
case "**": return "number";
|
|
1560
|
+
default: return "boolean";
|
|
1561
|
+
}
|
|
1562
|
+
case "Conditional": return union([of(expression.consequent), expression.alternate ? of(expression.alternate) : "null"]);
|
|
1563
|
+
case "Member": {
|
|
1564
|
+
const key = literalKey(expression.property) ?? "string";
|
|
1565
|
+
return `Get<${of(expression.object)}, ${key}>`;
|
|
1566
|
+
}
|
|
1567
|
+
case "Call": {
|
|
1568
|
+
const { callee } = expression;
|
|
1569
|
+
if (callee.type === "Member") {
|
|
1570
|
+
const key = literalKey(callee.property) ?? "string";
|
|
1571
|
+
return `Result<Get<${of(callee.object)}, ${key}>>`;
|
|
1572
|
+
}
|
|
1573
|
+
if (callee.type !== "Name") return "unknown";
|
|
1574
|
+
const name = callee.name;
|
|
1575
|
+
if (context.macros.has(name)) return "Markup";
|
|
1576
|
+
const builtin = BUILTIN_FUNCTIONS[name];
|
|
1577
|
+
if (builtin && !context.functions.includes(name)) return builtin;
|
|
1578
|
+
return `Result<Get<KilnFunctions, ${JSON.stringify(name)}>>`;
|
|
1579
|
+
}
|
|
1580
|
+
case "Filter": {
|
|
1581
|
+
const input = of(expression.input);
|
|
1582
|
+
const builtin = BUILTIN_FILTERS[expression.name];
|
|
1583
|
+
if (!builtin || context.filters.includes(expression.name)) return `Result<Get<KilnFilters, ${JSON.stringify(expression.name)}>>`;
|
|
1584
|
+
const arrow = expression.args[0]?.value;
|
|
1585
|
+
if (expression.name === "map" && arrow?.type === "Arrow") return `(${of(arrow.body)})[]`;
|
|
1586
|
+
return builtin(input, args(expression.args));
|
|
1587
|
+
}
|
|
1588
|
+
case "Test": return "boolean";
|
|
1589
|
+
case "OptionalChain": return of(expression.expression);
|
|
1590
|
+
case "Name": {
|
|
1591
|
+
if (expression.name === "loop") return "unknown";
|
|
1592
|
+
const binding = bindingAt(context.scopes, expression.name, expression.span.start);
|
|
1593
|
+
if (binding) return bindingType(binding.source, context, depth + 1);
|
|
1594
|
+
return `Get<KilnGlobals, ${JSON.stringify(expression.name)}>`;
|
|
1595
|
+
}
|
|
1596
|
+
default: return "unknown";
|
|
1597
|
+
}
|
|
1598
|
+
};
|
|
1599
|
+
/** The registries a project describes in TypeScript, by the kind of name the language server looks up. */
|
|
1600
|
+
const REGISTRIES = {
|
|
1601
|
+
function: "KilnFunctions",
|
|
1602
|
+
filter: "KilnFilters",
|
|
1603
|
+
test: "KilnTests",
|
|
1604
|
+
global: "KilnGlobals"
|
|
1605
|
+
};
|
|
1606
|
+
/** Checks a file of `type name = type` aliases with the project's TypeScript, and reads it with `use`. */
|
|
1607
|
+
const withTypes = (typescript, aliases, use) => {
|
|
1608
|
+
const source = PRELUDE + Object.entries(aliases).map(([name, type]) => `type ${name} = ${type};\n`).join("");
|
|
1609
|
+
return typescript.query(source, use);
|
|
1610
|
+
};
|
|
1611
|
+
/** The TypeScript type of a registered function, filter or test, by kind and name. */
|
|
1612
|
+
const callableType = (kind, name) => `Get<${REGISTRIES[kind]}, ${JSON.stringify(name)}>`;
|
|
1613
|
+
/** Its parameters as a tuple: a filter's first parameter is the value before the `|`, so `withInput` keeps it. */
|
|
1614
|
+
const parametersType = (kind, name, withInput = false) => {
|
|
1615
|
+
const params = `Params<${callableType(kind, name)}>`;
|
|
1616
|
+
return kind === "function" || withInput ? params : `Tail<${params}>`;
|
|
1617
|
+
};
|
|
1618
|
+
/** A host function, filter or test as hover shows it, such as `documents(collection: string): Promise<Page[]>`. */
|
|
1619
|
+
const describeCallable = (typescript, kind, name) => withTypes(typescript, {
|
|
1620
|
+
Registry: REGISTRIES[kind],
|
|
1621
|
+
Parameters: parametersType(kind, name),
|
|
1622
|
+
Return: `Returns<${callableType(kind, name)}>`
|
|
1623
|
+
}, (read) => {
|
|
1624
|
+
const member = read.members("Registry").find((item) => item.name === name);
|
|
1625
|
+
const parameters = read.describe("Parameters");
|
|
1626
|
+
const result = read.describe("Return");
|
|
1627
|
+
if (!member || !parameters || !result) return null;
|
|
1628
|
+
const list = parameters.replace(/^\[|\]$/g, "");
|
|
1629
|
+
const prefix = `${kind === "filter" ? "|" : kind === "test" ? "is " : ""}${name}(`;
|
|
1630
|
+
return {
|
|
1631
|
+
label: `${prefix}${list}): ${result}`,
|
|
1632
|
+
parameters: splitParameters(list).map((part) => ({
|
|
1633
|
+
name: /^(?:\.\.\.)?([\w$]+)/.exec(part.text)?.[1] ?? part.text,
|
|
1634
|
+
start: prefix.length + part.start,
|
|
1635
|
+
end: prefix.length + part.end
|
|
1636
|
+
})),
|
|
1637
|
+
member
|
|
1638
|
+
};
|
|
1639
|
+
});
|
|
1640
|
+
/** The names a function or filter takes as named arguments: the properties of its last parameter. */
|
|
1641
|
+
const namedOptions = (typescript, kind, name) => withTypes(typescript, { Options: `Members<Last<${parametersType(kind, name)}>>` }, (read) => read.members("Options")) ?? [];
|
|
1642
|
+
/** Checks arguments against their parameters with one TypeScript query. Null for an argument that fits. */
|
|
1643
|
+
const checkArguments = (typescript, checks) => {
|
|
1644
|
+
if (checks.length === 0) return [];
|
|
1645
|
+
const aliases = {};
|
|
1646
|
+
checks.forEach((check, index) => {
|
|
1647
|
+
const expected = "index" in check ? `Required<${check.parameters}>[${check.index}]` : `Exclude<Get<Last<${check.parameters}>, ${JSON.stringify(check.name)}>, undefined>`;
|
|
1648
|
+
aliases[`Fit${index}`] = "index" in check ? `Fits<${check.type}, ${expected}> extends true ? "ok" : "mismatch"` : `NamedFit<Last<${check.parameters}>, ${JSON.stringify(check.name)}, ${check.type}>`;
|
|
1649
|
+
aliases[`Expected${index}`] = expected;
|
|
1650
|
+
aliases[`Actual${index}`] = check.type;
|
|
1651
|
+
});
|
|
1652
|
+
return withTypes(typescript, aliases, (read) => checks.map((_, index) => {
|
|
1653
|
+
const fit = read.describe(`Fit${index}`);
|
|
1654
|
+
if (fit === "\"missing\"") return { kind: "missing" };
|
|
1655
|
+
if (fit !== "\"mismatch\"") return null;
|
|
1656
|
+
return {
|
|
1657
|
+
kind: "mismatch",
|
|
1658
|
+
expected: read.describe(`Expected${index}`) ?? "unknown",
|
|
1659
|
+
actual: read.describe(`Actual${index}`) ?? "unknown"
|
|
1660
|
+
};
|
|
1661
|
+
})) ?? checks.map(() => null);
|
|
1662
|
+
};
|
|
1663
|
+
//#endregion
|
|
1664
|
+
//#region src/features.ts
|
|
1665
|
+
const analyse = async (project, name, text) => {
|
|
1666
|
+
try {
|
|
1667
|
+
return await project.kiln.analyze(name, text);
|
|
1668
|
+
} catch {
|
|
1669
|
+
return null;
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1672
|
+
/** Writes a prop type as it appears in `props`, such as `"a" | "b"` or `{ url?: string }[]`. */
|
|
1673
|
+
const describeType = (type) => {
|
|
1674
|
+
if (type === null) return "any";
|
|
1675
|
+
return type.map((member) => {
|
|
1676
|
+
if (typeof member === "string") return member;
|
|
1677
|
+
if ("literal" in member) return JSON.stringify(member.literal);
|
|
1678
|
+
if ("named" in member) return member.named;
|
|
1679
|
+
if ("list" in member) {
|
|
1680
|
+
const inner = describeType(member.list);
|
|
1681
|
+
return member.list.length > 1 ? `(${inner})[]` : `${inner}[]`;
|
|
1682
|
+
}
|
|
1683
|
+
return `{ ${member.shape.map((key) => `${key.name}${key.optional ? "?" : ""}: ${describeType(key.type)}`).join(", ")} }`;
|
|
1684
|
+
}).join(" | ");
|
|
1685
|
+
};
|
|
1686
|
+
const describeProp = (prop) => `${prop.name}${prop.required ? "" : "?"}: ${describeType(prop.type)}`;
|
|
1687
|
+
const markdown = (value) => ({
|
|
1688
|
+
kind: MarkupKind.Markdown,
|
|
1689
|
+
value
|
|
1690
|
+
});
|
|
1691
|
+
/** Markdown for an item's docs, or nothing when it has none. */
|
|
1692
|
+
const documentation = (kind, name) => {
|
|
1693
|
+
const docs = docsFor(kind, name);
|
|
1694
|
+
return docs ? markdown(docs) : void 0;
|
|
1695
|
+
};
|
|
1696
|
+
const builtinItems = (builtins, docKind, kind, extra) => [...Object.entries(builtins).map(([label, doc]) => ({
|
|
1697
|
+
label,
|
|
1698
|
+
kind,
|
|
1699
|
+
detail: doc.signature,
|
|
1700
|
+
documentation: documentation(docKind, label)
|
|
1701
|
+
})), ...extra.filter((name) => !(name in builtins)).map((label) => ({
|
|
1702
|
+
label,
|
|
1703
|
+
kind,
|
|
1704
|
+
detail: "From your project"
|
|
1705
|
+
}))];
|
|
1706
|
+
/** Parses a template and finds its variables, with what `expressionType` needs to work out their types. */
|
|
1707
|
+
const templateTypes = (open, text) => {
|
|
1708
|
+
const manifest = open.project.manifest;
|
|
1709
|
+
const scopes = templateScopes(text, (name) => name in TESTS || (manifest?.tests.includes(name) ?? false));
|
|
1710
|
+
const macros = /* @__PURE__ */ new Set([...macrosIn(text).keys(), ...scopes.bindings.filter((binding) => binding.source.kind === "import").map((binding) => binding.name)]);
|
|
1711
|
+
return {
|
|
1712
|
+
scopes,
|
|
1713
|
+
context: {
|
|
1714
|
+
scopes,
|
|
1715
|
+
functions: manifest?.functions ?? [],
|
|
1716
|
+
filters: manifest?.filters ?? [],
|
|
1717
|
+
macros
|
|
1718
|
+
}
|
|
1719
|
+
};
|
|
1720
|
+
};
|
|
1721
|
+
/** The property under the cursor in `a.b`, with the expression it's read from. */
|
|
1722
|
+
const propertyAt = (scopes, text, offset) => {
|
|
1723
|
+
const path = expressionPathAt(scopes.ast, offset);
|
|
1724
|
+
for (let index = path.length - 1; index >= 0; index--) {
|
|
1725
|
+
const node = path[index];
|
|
1726
|
+
if (node.type !== "Member") continue;
|
|
1727
|
+
const { property } = node;
|
|
1728
|
+
if (property.type === "Literal" && typeof property.value === "string" && offset >= property.span.start && offset <= property.span.end && text[property.span.start - 1] === ".") return {
|
|
1729
|
+
name: property.value,
|
|
1730
|
+
object: node.object,
|
|
1731
|
+
span: property.span
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
return null;
|
|
1735
|
+
};
|
|
1736
|
+
/** A member's doc comment and tags, as Markdown. */
|
|
1737
|
+
const memberDocs = (member) => [member.docs, ...member.tags.map((tag) => `*@${tag.name}* ${tag.text}`.trim())].filter(Boolean).join("\n\n");
|
|
1738
|
+
/** The properties TypeScript finds on the value of `object`. Empty without TypeScript. */
|
|
1739
|
+
const membersOf = async (open, object, context) => {
|
|
1740
|
+
const typescript = await open.project.typescript();
|
|
1741
|
+
if (!typescript) return [];
|
|
1742
|
+
const type = expressionType(object, context);
|
|
1743
|
+
return withTypes(typescript, { Value: `Members<${type}>` }, (read) => read.members("Value")) ?? [];
|
|
1744
|
+
};
|
|
1745
|
+
/** A function, filter, test or global the project describes in TypeScript. */
|
|
1746
|
+
const registered = async (open, kind, name) => {
|
|
1747
|
+
const typescript = await open.project.typescript();
|
|
1748
|
+
if (!typescript) return null;
|
|
1749
|
+
return withTypes(typescript, { Registry: REGISTRIES[kind] }, (read) => read.members("Registry").find((member) => member.name === name)) ?? null;
|
|
1750
|
+
};
|
|
1751
|
+
const BINDING_NOTES = {
|
|
1752
|
+
prop: "A prop of this template.",
|
|
1753
|
+
rest: "The variables passed to this template that it doesn't declare.",
|
|
1754
|
+
set: "Set in this template.",
|
|
1755
|
+
capture: "Set in this template, from a `set` block.",
|
|
1756
|
+
item: "A loop variable.",
|
|
1757
|
+
key: "A loop variable.",
|
|
1758
|
+
arrow: "An arrow function's parameter.",
|
|
1759
|
+
with: "From the `with` tag.",
|
|
1760
|
+
param: "A macro argument.",
|
|
1761
|
+
import: "Imported macros."
|
|
1762
|
+
};
|
|
1763
|
+
const complete = async (open, offset) => {
|
|
1764
|
+
const { document, project, name } = open;
|
|
1765
|
+
const text = document.getText();
|
|
1766
|
+
const context = cursorContext(text, offset);
|
|
1767
|
+
if (!context) return enclosingDelimiter(text, offset) ? [] : htmlCompletions(document, offset);
|
|
1768
|
+
const replace = (end = offset) => ({
|
|
1769
|
+
start: document.positionAt(context.start),
|
|
1770
|
+
end: document.positionAt(end)
|
|
1771
|
+
});
|
|
1772
|
+
const manifest = project.manifest;
|
|
1773
|
+
switch (context.kind) {
|
|
1774
|
+
case "tag": {
|
|
1775
|
+
const items = Object.entries(TAGS).map(([label, tag]) => ({
|
|
1776
|
+
label,
|
|
1777
|
+
kind: CompletionItemKind.Keyword,
|
|
1778
|
+
documentation: documentation("tags", label),
|
|
1779
|
+
insertTextFormat: InsertTextFormat.Snippet,
|
|
1780
|
+
insertTextMode: InsertTextMode.adjustIndentation,
|
|
1781
|
+
textEdit: {
|
|
1782
|
+
range: replace(context.end ?? offset),
|
|
1783
|
+
newText: tag.snippet
|
|
1784
|
+
}
|
|
1785
|
+
}));
|
|
1786
|
+
if (context.open) {
|
|
1787
|
+
const label = `end${context.open}`;
|
|
1788
|
+
items.unshift({
|
|
1789
|
+
label,
|
|
1790
|
+
kind: CompletionItemKind.Keyword,
|
|
1791
|
+
detail: `Closes the open ${context.open}`,
|
|
1792
|
+
sortText: "0",
|
|
1793
|
+
textEdit: {
|
|
1794
|
+
range: replace(context.end ?? offset),
|
|
1795
|
+
newText: `${label} %}`
|
|
1796
|
+
}
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
return items;
|
|
1800
|
+
}
|
|
1801
|
+
case "template": return (await listTemplates(project)).map((template) => {
|
|
1802
|
+
const label = template.replace(/\.kiln$/, "");
|
|
1803
|
+
return {
|
|
1804
|
+
label,
|
|
1805
|
+
kind: CompletionItemKind.File,
|
|
1806
|
+
textEdit: {
|
|
1807
|
+
range: replace(),
|
|
1808
|
+
newText: label
|
|
1809
|
+
}
|
|
1810
|
+
};
|
|
1811
|
+
});
|
|
1812
|
+
case "filter": return builtinItems(FILTERS, "filters", CompletionItemKind.Function, manifest?.filters ?? []);
|
|
1813
|
+
case "test": return builtinItems(TESTS, "tests", CompletionItemKind.Function, manifest?.tests ?? []);
|
|
1814
|
+
case "member": {
|
|
1815
|
+
const patched = context.start === offset ? `${text.slice(0, offset)}__kiln__${text.slice(offset)}` : text;
|
|
1816
|
+
const types = templateTypes(open, patched);
|
|
1817
|
+
const property = propertyAt(types.scopes, patched, context.start);
|
|
1818
|
+
if (!property) return [];
|
|
1819
|
+
return (await membersOf(open, property.object, types.context)).map((member) => ({
|
|
1820
|
+
label: member.name,
|
|
1821
|
+
kind: CompletionItemKind.Property,
|
|
1822
|
+
detail: `${member.name}${member.optional ? "?" : ""}: ${member.type}`,
|
|
1823
|
+
documentation: memberDocs(member) ? markdown(memberDocs(member)) : void 0,
|
|
1824
|
+
textEdit: {
|
|
1825
|
+
range: replace(),
|
|
1826
|
+
newText: member.name
|
|
1827
|
+
}
|
|
1828
|
+
}));
|
|
1829
|
+
}
|
|
1830
|
+
case "loop": return Object.entries(LOOP_PROPERTIES).map(([label, description]) => ({
|
|
1831
|
+
label,
|
|
1832
|
+
kind: CompletionItemKind.Property,
|
|
1833
|
+
documentation: markdown(description)
|
|
1834
|
+
}));
|
|
1835
|
+
case "prop": {
|
|
1836
|
+
const target = resolveName(context.template, name ?? void 0);
|
|
1837
|
+
return ((target ? await analyse(project, target) : null)?.props ?? []).map((prop) => ({
|
|
1838
|
+
label: prop.name,
|
|
1839
|
+
kind: CompletionItemKind.Field,
|
|
1840
|
+
detail: describeProp(prop),
|
|
1841
|
+
insertText: `${prop.name}: `
|
|
1842
|
+
}));
|
|
1843
|
+
}
|
|
1844
|
+
case "expression": {
|
|
1845
|
+
const analysis = name ? await analyse(project, name, text) : null;
|
|
1846
|
+
const { scopes } = templateTypes(open, text);
|
|
1847
|
+
const variables = /* @__PURE__ */ new Set([
|
|
1848
|
+
...analysis?.props.map((prop) => prop.name) ?? [],
|
|
1849
|
+
...analysis?.rest ? [analysis.rest] : [],
|
|
1850
|
+
...bindingsAt(scopes, offset).map((binding) => binding.name),
|
|
1851
|
+
...macrosIn(text).keys(),
|
|
1852
|
+
...manifest?.globals ?? []
|
|
1853
|
+
]);
|
|
1854
|
+
const props = new Map(analysis?.props.map((prop) => [prop.name, prop]));
|
|
1855
|
+
return [
|
|
1856
|
+
...await namedArgumentItems(open, text, offset),
|
|
1857
|
+
...[...variables].map((label) => {
|
|
1858
|
+
const prop = props.get(label);
|
|
1859
|
+
return {
|
|
1860
|
+
label,
|
|
1861
|
+
kind: CompletionItemKind.Variable,
|
|
1862
|
+
detail: prop ? describeProp(prop) : void 0
|
|
1863
|
+
};
|
|
1864
|
+
}),
|
|
1865
|
+
...builtinItems(FUNCTIONS, "functions", CompletionItemKind.Function, manifest?.functions ?? []),
|
|
1866
|
+
...KEYWORDS$1.map((label) => ({
|
|
1867
|
+
label,
|
|
1868
|
+
kind: CompletionItemKind.Keyword
|
|
1869
|
+
}))
|
|
1870
|
+
];
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
};
|
|
1874
|
+
/** The `{% macro name(params) %}` definitions in a template, by name. */
|
|
1875
|
+
const macrosIn = (text) => new Map([...text.matchAll(/\{%-?\s*macro\s+(\w+)\s*\(([^)]*)\)/g)].map((match) => [match[1], {
|
|
1876
|
+
params: match[2].trim(),
|
|
1877
|
+
start: match.index
|
|
1878
|
+
}]));
|
|
1879
|
+
const codeBlock = (code, language = "kiln") => `\`\`\`${language}\n${code}\n\`\`\``;
|
|
1880
|
+
/** A hover's parts, such as a signature and its docs, with a rule between each. Missing parts are left out. */
|
|
1881
|
+
const sections = (...parts) => parts.filter(Boolean).join("\n\n---\n\n");
|
|
1882
|
+
/** A tag's syntax, from its completion snippet: named placeholders become their names, and the rest become `…`. */
|
|
1883
|
+
const tagSyntax = (snippet) => `{% ${snippet.replace(/\$\{\d+:([^}]*)\}/g, "$1").replace(/\$\{?\d+\}?/g, "…")}`;
|
|
1884
|
+
/** A registered function, filter or test as the project's TypeScript describes it: its signature, then its docs. */
|
|
1885
|
+
const describedCallable = async (open, kind, name) => {
|
|
1886
|
+
const typescript = await open.project.typescript();
|
|
1887
|
+
const described = typescript ? describeCallable(typescript, kind, name) : null;
|
|
1888
|
+
return described ? sections(codeBlock(described.label, "ts"), memberDocs(described.member)) : null;
|
|
1889
|
+
};
|
|
1890
|
+
/** The variable a name refers to: the one it declares when the cursor is on a declaration, or the one in scope. */
|
|
1891
|
+
const bindingFor = (scopes, symbol) => scopes.bindings.find((binding) => binding.name === symbol.name && symbol.start >= binding.definition.start && symbol.start < binding.definition.end) ?? bindingAt(scopes, symbol.name, symbol.start);
|
|
1892
|
+
/** The property under the cursor in `a.b`, and what TypeScript knows about it. */
|
|
1893
|
+
const memberAt = async (open, text, offset) => {
|
|
1894
|
+
const types = templateTypes(open, text);
|
|
1895
|
+
const property = propertyAt(types.scopes, text, offset);
|
|
1896
|
+
if (!property) return null;
|
|
1897
|
+
const member = (await membersOf(open, property.object, types.context)).find((item) => item.name === property.name);
|
|
1898
|
+
return member ? {
|
|
1899
|
+
member,
|
|
1900
|
+
span: property.span
|
|
1901
|
+
} : null;
|
|
1902
|
+
};
|
|
1903
|
+
const hover = async (open, offset) => {
|
|
1904
|
+
const { document, project, name } = open;
|
|
1905
|
+
const text = document.getText();
|
|
1906
|
+
const symbol = symbolAt(text, offset);
|
|
1907
|
+
const rangeOf = (span) => ({
|
|
1908
|
+
start: document.positionAt(span.start),
|
|
1909
|
+
end: document.positionAt(span.end)
|
|
1910
|
+
});
|
|
1911
|
+
if (!symbol) {
|
|
1912
|
+
if (!enclosingDelimiter(text, offset)) return htmlHover(document, offset);
|
|
1913
|
+
const found = await memberAt(open, text, offset);
|
|
1914
|
+
if (!found) return null;
|
|
1915
|
+
const { member } = found;
|
|
1916
|
+
return {
|
|
1917
|
+
contents: markdown(sections(codeBlock(`${member.name}${member.optional ? "?" : ""}: ${member.type}`, "ts"), memberDocs(member))),
|
|
1918
|
+
range: rangeOf(found.span)
|
|
1919
|
+
};
|
|
1920
|
+
}
|
|
1921
|
+
const fromProject = (names, what) => names?.includes(symbol.name) ? `A ${what} your project registers.` : null;
|
|
1922
|
+
/** Whether the project registers a name, which replaces a built-in with the same name. */
|
|
1923
|
+
const registers = (names) => names?.includes(symbol.name) ?? false;
|
|
1924
|
+
let value = null;
|
|
1925
|
+
/** A built-in's signature, then its docs. */
|
|
1926
|
+
const builtin = (signature, kind) => sections(codeBlock(signature), docsFor(kind, symbol.name));
|
|
1927
|
+
switch (symbol.kind) {
|
|
1928
|
+
case "tag": {
|
|
1929
|
+
const tag = symbol.name in TAGS ? symbol.name : symbol.name.replace(/^end/, "");
|
|
1930
|
+
const doc = TAGS[tag];
|
|
1931
|
+
value = sections(doc && codeBlock(tagSyntax(doc.snippet)), docsFor("tags", tag));
|
|
1932
|
+
break;
|
|
1933
|
+
}
|
|
1934
|
+
case "filter": {
|
|
1935
|
+
const doc = FILTERS[symbol.name];
|
|
1936
|
+
value = doc && !registers(project.manifest?.filters) ? builtin(`|${doc.signature}`, "filters") : await describedCallable(open, "filter", symbol.name) ?? fromProject(project.manifest?.filters, "filter");
|
|
1937
|
+
break;
|
|
1938
|
+
}
|
|
1939
|
+
case "test": {
|
|
1940
|
+
const doc = TESTS[symbol.name];
|
|
1941
|
+
value = doc && !registers(project.manifest?.tests) ? builtin(`is ${doc.signature}`, "tests") : await describedCallable(open, "test", symbol.name) ?? fromProject(project.manifest?.tests, "test");
|
|
1942
|
+
break;
|
|
1943
|
+
}
|
|
1944
|
+
case "loop": {
|
|
1945
|
+
const description = LOOP_PROPERTIES[symbol.name];
|
|
1946
|
+
value = description ? sections(codeBlock(`loop.${symbol.name}`), description) : null;
|
|
1947
|
+
break;
|
|
1948
|
+
}
|
|
1949
|
+
case "function": {
|
|
1950
|
+
const macro = macrosIn(text).get(symbol.name);
|
|
1951
|
+
const doc = FUNCTIONS[symbol.name];
|
|
1952
|
+
if (macro) value = codeBlock(`macro ${symbol.name}(${macro.params})`);
|
|
1953
|
+
else if (doc && !registers(project.manifest?.functions)) value = builtin(doc.signature, "functions");
|
|
1954
|
+
else value = await describedCallable(open, "function", symbol.name) ?? fromProject(project.manifest?.functions, "function");
|
|
1955
|
+
break;
|
|
1956
|
+
}
|
|
1957
|
+
case "variable": {
|
|
1958
|
+
const types = templateTypes(open, text);
|
|
1959
|
+
const binding = bindingFor(types.scopes, symbol);
|
|
1960
|
+
if (binding?.source.kind === "prop" || binding?.source.kind === "rest") {
|
|
1961
|
+
const prop = (name ? await analyse(project, name, text) : null)?.props.find((item) => item.name === symbol.name);
|
|
1962
|
+
value = sections(codeBlock(prop ? describeProp(prop) : `...${symbol.name}`), BINDING_NOTES[binding.source.kind]);
|
|
1963
|
+
} else if (binding) {
|
|
1964
|
+
const typescript = await project.typescript();
|
|
1965
|
+
const type = typescript ? withTypes(typescript, { Value: bindingType(binding.source, types.context) }, (read) => read.describe("Value")) : null;
|
|
1966
|
+
value = sections(codeBlock(type && type !== "unknown" ? `${symbol.name}: ${type}` : symbol.name, "ts"), BINDING_NOTES[binding.source.kind]);
|
|
1967
|
+
} else {
|
|
1968
|
+
const global = await registered(open, "global", symbol.name);
|
|
1969
|
+
value = global ? sections(codeBlock(`${symbol.name}: ${global.type}`, "ts"), memberDocs(global)) : project.manifest?.globals.includes(symbol.name) ? "A global your project provides." : null;
|
|
1970
|
+
}
|
|
1971
|
+
break;
|
|
1972
|
+
}
|
|
1973
|
+
case "template": {
|
|
1974
|
+
const target = resolveName(symbol.name, name ?? void 0);
|
|
1975
|
+
const analysis = target ? await analyse(project, target) : null;
|
|
1976
|
+
if (!target || !analysis) {
|
|
1977
|
+
value = project.manifest ? `Template \`${symbol.name}\` was not found.` : null;
|
|
1978
|
+
break;
|
|
1979
|
+
}
|
|
1980
|
+
const lines = [`**${target}**`];
|
|
1981
|
+
if (analysis.props.length > 0 || analysis.rest) {
|
|
1982
|
+
const props = analysis.props.map(describeProp);
|
|
1983
|
+
if (analysis.rest) props.push(`...${analysis.rest}`);
|
|
1984
|
+
lines.push(codeBlock(`props ${props.join(", ")}`));
|
|
1985
|
+
}
|
|
1986
|
+
if (analysis.blocks.length > 0) lines.push(`Blocks: ${analysis.blocks.map((block) => `\`${block}\``).join(", ")}`);
|
|
1987
|
+
value = lines.join("\n\n");
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
return value ? {
|
|
1991
|
+
contents: markdown(value),
|
|
1992
|
+
range: rangeOf(symbol)
|
|
1993
|
+
} : null;
|
|
1994
|
+
};
|
|
1995
|
+
const point = (line, character) => {
|
|
1996
|
+
const position = {
|
|
1997
|
+
line,
|
|
1998
|
+
character
|
|
1999
|
+
};
|
|
2000
|
+
return {
|
|
2001
|
+
start: position,
|
|
2002
|
+
end: position
|
|
2003
|
+
};
|
|
2004
|
+
};
|
|
2005
|
+
/** The line and column of an offset in another template's text. */
|
|
2006
|
+
const positionIn = (text, offset) => {
|
|
2007
|
+
const lines = text.slice(0, offset).split("\n");
|
|
2008
|
+
return {
|
|
2009
|
+
line: lines.length - 1,
|
|
2010
|
+
character: lines.at(-1).length
|
|
2011
|
+
};
|
|
2012
|
+
};
|
|
2013
|
+
/**
|
|
2014
|
+
* Where the name under the cursor is defined. The link's origin is the whole name, so editors underline all of
|
|
2015
|
+
* `"layouts/base"` rather than one word of it.
|
|
2016
|
+
*
|
|
2017
|
+
* Variables go to where the template declares them. Without `kiln.json`, template names have no root, so nothing in
|
|
2018
|
+
* other templates is found. With the project's TypeScript, its functions, filters, tests, globals and the properties of
|
|
2019
|
+
* typed values go to their TypeScript source.
|
|
2020
|
+
*/
|
|
2021
|
+
const definition = async (open, offset) => {
|
|
2022
|
+
const { document, project, name } = open;
|
|
2023
|
+
const text = document.getText();
|
|
2024
|
+
const link = (origin, uri, range) => ({
|
|
2025
|
+
originSelectionRange: {
|
|
2026
|
+
start: document.positionAt(origin.start),
|
|
2027
|
+
end: document.positionAt(origin.end)
|
|
2028
|
+
},
|
|
2029
|
+
targetUri: uri,
|
|
2030
|
+
targetRange: range,
|
|
2031
|
+
targetSelectionRange: range
|
|
2032
|
+
});
|
|
2033
|
+
const inSource = (origin, location) => link(origin, pathToFileURL(location.path).href, point(location.line, location.character));
|
|
2034
|
+
const symbol = symbolAt(text, offset);
|
|
2035
|
+
if (!symbol) {
|
|
2036
|
+
if (!enclosingDelimiter(text, offset)) return null;
|
|
2037
|
+
const found = await memberAt(open, text, offset);
|
|
2038
|
+
return found?.member.location ? inSource(found.span, found.member.location) : null;
|
|
2039
|
+
}
|
|
2040
|
+
const inTemplate = (template, line = 0, character = 0) => link(symbol, pathToFileURL(pathOf(project, template)).href, point(line, character));
|
|
2041
|
+
const load = (template) => project.overlay.get(template) ?? readTemplate(project, template);
|
|
2042
|
+
/** Goes to a function, filter, test or global in the project's TypeScript. */
|
|
2043
|
+
const inTypeScript = async (kind) => {
|
|
2044
|
+
const member = await registered(open, kind, symbol.name);
|
|
2045
|
+
return member?.location ? inSource(symbol, member.location) : null;
|
|
2046
|
+
};
|
|
2047
|
+
const blockName = symbol.kind === "variable" && /\{%-?\s*block\s+$/.test(text.slice(Math.max(0, symbol.start - 40), symbol.start));
|
|
2048
|
+
if (symbol.kind === "variable" && !blockName) {
|
|
2049
|
+
const binding = bindingFor(templateTypes(open, text).scopes, symbol);
|
|
2050
|
+
if (binding) return link(symbol, document.uri, {
|
|
2051
|
+
start: document.positionAt(binding.definition.start),
|
|
2052
|
+
end: document.positionAt(binding.definition.end)
|
|
2053
|
+
});
|
|
2054
|
+
return inTypeScript("global");
|
|
2055
|
+
}
|
|
2056
|
+
if (symbol.kind === "function") {
|
|
2057
|
+
const local = macrosIn(text).get(symbol.name);
|
|
2058
|
+
if (local) {
|
|
2059
|
+
const position = document.positionAt(local.start);
|
|
2060
|
+
return link(symbol, document.uri, point(position.line, position.character));
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
if (!project.manifest) return null;
|
|
2064
|
+
if (symbol.kind === "template") {
|
|
2065
|
+
const target = resolveName(symbol.name, name ?? void 0);
|
|
2066
|
+
return target ? inTemplate(target) : null;
|
|
2067
|
+
}
|
|
2068
|
+
if (symbol.kind === "filter" || symbol.kind === "test") return inTypeScript(symbol.kind);
|
|
2069
|
+
if (symbol.kind === "function") {
|
|
2070
|
+
for (const match of text.matchAll(/\{%-?\s*from\s+(["'])([^"']*)\1\s+import\s+([^%]*)%\}/g)) for (const part of match[3].split(",")) {
|
|
2071
|
+
const [original, alias = original] = part.trim().split(/\s+as\s+/);
|
|
2072
|
+
if (alias !== symbol.name || !original) continue;
|
|
2073
|
+
const target = resolveName(match[2], name ?? void 0);
|
|
2074
|
+
const source = target ? await load(target) : null;
|
|
2075
|
+
const macro = source ? macrosIn(source).get(original) : null;
|
|
2076
|
+
if (target && source && macro) {
|
|
2077
|
+
const position = positionIn(source, macro.start);
|
|
2078
|
+
return inTemplate(target, position.line, position.character);
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
return inTypeScript("function");
|
|
2082
|
+
}
|
|
2083
|
+
if (blockName && name) {
|
|
2084
|
+
let current = name;
|
|
2085
|
+
for (let depth = 0; depth < 20; depth++) {
|
|
2086
|
+
const parent = (await analyse(project, current, current === name ? text : void 0))?.templates.find((ref) => ref.kind === "extends")?.name;
|
|
2087
|
+
if (!parent) return null;
|
|
2088
|
+
const source = await load(parent);
|
|
2089
|
+
const block = source ? new RegExp(`\\{%-?\\s*block\\s+${symbol.name}\\b`).exec(source) : null;
|
|
2090
|
+
if (source && block) {
|
|
2091
|
+
const position = positionIn(source, block.index);
|
|
2092
|
+
return inTemplate(parent, position.line, position.character);
|
|
2093
|
+
}
|
|
2094
|
+
current = parent;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
return null;
|
|
2098
|
+
};
|
|
2099
|
+
const readTemplate = async (project, name) => {
|
|
2100
|
+
try {
|
|
2101
|
+
return await readFile(pathOf(project, name), "utf8");
|
|
2102
|
+
} catch {
|
|
2103
|
+
return null;
|
|
2104
|
+
}
|
|
2105
|
+
};
|
|
2106
|
+
const links = (open) => {
|
|
2107
|
+
const { document, project, name } = open;
|
|
2108
|
+
if (!project.manifest) return [];
|
|
2109
|
+
return templateStrings(document.getText()).flatMap((found) => {
|
|
2110
|
+
const target = resolveName(found.name, name ?? void 0);
|
|
2111
|
+
if (!target) return [];
|
|
2112
|
+
return [{
|
|
2113
|
+
range: {
|
|
2114
|
+
start: document.positionAt(found.start),
|
|
2115
|
+
end: document.positionAt(found.end)
|
|
2116
|
+
},
|
|
2117
|
+
target: pathToFileURL(pathOf(project, target)).href
|
|
2118
|
+
}];
|
|
2119
|
+
});
|
|
2120
|
+
};
|
|
2121
|
+
const SYMBOL_KINDS = {
|
|
2122
|
+
block: SymbolKind.Module,
|
|
2123
|
+
macro: SymbolKind.Function,
|
|
2124
|
+
slot: SymbolKind.Field,
|
|
2125
|
+
fill: SymbolKind.Field
|
|
2126
|
+
};
|
|
2127
|
+
const symbols = (document) => {
|
|
2128
|
+
const text = document.getText();
|
|
2129
|
+
const found = [];
|
|
2130
|
+
for (const match of text.matchAll(/\{%-?\s*(block|macro|slot|fill)\b\s*(["']?)([\w-]*)\2[^%]*%\}/g)) {
|
|
2131
|
+
const kind = match[1];
|
|
2132
|
+
const range = {
|
|
2133
|
+
start: document.positionAt(match.index),
|
|
2134
|
+
end: document.positionAt(match.index + match[0].length)
|
|
2135
|
+
};
|
|
2136
|
+
found.push({
|
|
2137
|
+
name: match[3] || (kind === "slot" ? "default" : kind),
|
|
2138
|
+
detail: kind,
|
|
2139
|
+
kind: SYMBOL_KINDS[kind] ?? SymbolKind.Field,
|
|
2140
|
+
range,
|
|
2141
|
+
selectionRange: range
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
return found;
|
|
2145
|
+
};
|
|
2146
|
+
/** Whether the project registers a function or filter, which replaces a built-in with the same name. */
|
|
2147
|
+
const isRegistered = (project, kind, name) => (kind === "function" ? project.manifest?.functions : project.manifest?.filters)?.includes(name) ?? false;
|
|
2148
|
+
/** A built-in's documented signature, such as `round(precision?, method?)`, when it takes arguments. */
|
|
2149
|
+
const builtinSignature = (kind, name) => {
|
|
2150
|
+
const doc = (kind === "function" ? FUNCTIONS : FILTERS)[name];
|
|
2151
|
+
if (!doc?.signature.includes("(")) return null;
|
|
2152
|
+
const label = `${kind === "filter" ? "|" : ""}${doc.signature}`;
|
|
2153
|
+
const open = label.indexOf("(") + 1;
|
|
2154
|
+
const list = label.slice(open, label.lastIndexOf(")"));
|
|
2155
|
+
return {
|
|
2156
|
+
label,
|
|
2157
|
+
parameters: splitParameters(list).map((part) => ({
|
|
2158
|
+
name: /^(?:\.\.\.)?(\w+)/.exec(part.text)?.[1] ?? part.text,
|
|
2159
|
+
start: open + part.start,
|
|
2160
|
+
end: open + part.end
|
|
2161
|
+
}))
|
|
2162
|
+
};
|
|
2163
|
+
};
|
|
2164
|
+
/** Named arguments the function or filter at the cursor takes, such as `limit:`, when an argument could start there. */
|
|
2165
|
+
const namedArgumentItems = async (open, text, offset) => {
|
|
2166
|
+
const call = callContext(text, offset);
|
|
2167
|
+
if (!call?.namePosition) return [];
|
|
2168
|
+
const range = {
|
|
2169
|
+
start: open.document.positionAt(call.start),
|
|
2170
|
+
end: open.document.positionAt(offset)
|
|
2171
|
+
};
|
|
2172
|
+
const item = (label, detail, docs) => ({
|
|
2173
|
+
label: `${label}:`,
|
|
2174
|
+
kind: CompletionItemKind.Field,
|
|
2175
|
+
detail,
|
|
2176
|
+
documentation: docs ? markdown(docs) : void 0,
|
|
2177
|
+
sortText: `0${label}`,
|
|
2178
|
+
textEdit: {
|
|
2179
|
+
range,
|
|
2180
|
+
newText: `${label}: `
|
|
2181
|
+
}
|
|
2182
|
+
});
|
|
2183
|
+
const builtin = isRegistered(open.project, call.kind, call.name) ? null : builtinSignature(call.kind, call.name);
|
|
2184
|
+
if (builtin) return builtin.parameters.map((param) => item(param.name));
|
|
2185
|
+
if (call.kind === "function" && macrosIn(text).has(call.name)) return [];
|
|
2186
|
+
const typescript = await open.project.typescript();
|
|
2187
|
+
if (!typescript) return [];
|
|
2188
|
+
return namedOptions(typescript, call.kind, call.name).map((member) => item(member.name, `${member.name}${member.optional ? "?" : ""}: ${member.type}`, memberDocs(member)));
|
|
2189
|
+
};
|
|
2190
|
+
/**
|
|
2191
|
+
* The parameters of the function or filter whose arguments the cursor is in, with the one it's on active. Named
|
|
2192
|
+
* arguments are gathered into the last parameter, so a named argument makes that one active and shows its own type.
|
|
2193
|
+
*/
|
|
2194
|
+
const signatureHelp = async (open, offset) => {
|
|
2195
|
+
const text = open.document.getText();
|
|
2196
|
+
const call = callContext(text, offset);
|
|
2197
|
+
if (!call) return null;
|
|
2198
|
+
let signature = null;
|
|
2199
|
+
const macro = call.kind === "function" ? macrosIn(text).get(call.name) : null;
|
|
2200
|
+
if (macro) signature = {
|
|
2201
|
+
label: `${call.name}(${macro.params})`,
|
|
2202
|
+
parameters: splitParameters(macro.params).map((part) => ({
|
|
2203
|
+
name: /^\w+/.exec(part.text)?.[0] ?? part.text,
|
|
2204
|
+
start: call.name.length + 1 + part.start,
|
|
2205
|
+
end: call.name.length + 1 + part.end
|
|
2206
|
+
}))
|
|
2207
|
+
};
|
|
2208
|
+
else if (!isRegistered(open.project, call.kind, call.name)) {
|
|
2209
|
+
const builtin = builtinSignature(call.kind, call.name);
|
|
2210
|
+
if (builtin) signature = {
|
|
2211
|
+
...builtin,
|
|
2212
|
+
docs: docsFor(call.kind === "function" ? "functions" : "filters", call.name)
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
const typescript = signature ? null : await open.project.typescript();
|
|
2216
|
+
if (typescript) {
|
|
2217
|
+
const described = describeCallable(typescript, call.kind, call.name);
|
|
2218
|
+
if (described) {
|
|
2219
|
+
const params = new Map(described.member.tags.flatMap((tag) => {
|
|
2220
|
+
const match = /^(\w+)\s*(.*)$/s.exec(tag.text);
|
|
2221
|
+
return tag.name === "param" && match ? [[match[1], match[2]]] : [];
|
|
2222
|
+
}));
|
|
2223
|
+
signature = {
|
|
2224
|
+
label: described.label,
|
|
2225
|
+
parameters: described.parameters,
|
|
2226
|
+
docs: described.member.docs,
|
|
2227
|
+
paramDocs: params
|
|
2228
|
+
};
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
if (!signature || signature.parameters.length === 0) return null;
|
|
2232
|
+
const last = signature.parameters.length - 1;
|
|
2233
|
+
const active = call.named || call.sawNamed ? last : Math.min(call.index, last);
|
|
2234
|
+
let namedDocs = null;
|
|
2235
|
+
if (call.named && typescript) {
|
|
2236
|
+
const option = namedOptions(typescript, call.kind, call.name).find((member) => member.name === call.named);
|
|
2237
|
+
if (option) namedDocs = sections(codeBlock(`${option.name}${option.optional ? "?" : ""}: ${option.type}`, "ts"), memberDocs(option));
|
|
2238
|
+
}
|
|
2239
|
+
const found = signature;
|
|
2240
|
+
return {
|
|
2241
|
+
signatures: [{
|
|
2242
|
+
label: found.label,
|
|
2243
|
+
documentation: found.docs ? markdown(found.docs) : void 0,
|
|
2244
|
+
parameters: found.parameters.map((param, index) => {
|
|
2245
|
+
const docs = index === active && namedDocs ? namedDocs : found.paramDocs?.get(param.name);
|
|
2246
|
+
return {
|
|
2247
|
+
label: [param.start, param.end],
|
|
2248
|
+
documentation: docs ? markdown(docs) : void 0
|
|
2249
|
+
};
|
|
2250
|
+
})
|
|
2251
|
+
}],
|
|
2252
|
+
activeSignature: 0,
|
|
2253
|
+
activeParameter: active
|
|
2254
|
+
};
|
|
2255
|
+
};
|
|
2256
|
+
/**
|
|
2257
|
+
* Checks the arguments of calls to the project's own functions and filters against their TypeScript types. A filter's
|
|
2258
|
+
* value before the `|` is its first argument. Calls with `...` spreads, and arguments whose type is unknown, aren't
|
|
2259
|
+
* checked.
|
|
2260
|
+
*/
|
|
2261
|
+
const argumentWarnings = async (open) => {
|
|
2262
|
+
const typescript = await open.project.typescript();
|
|
2263
|
+
if (!typescript) return [];
|
|
2264
|
+
const text = open.document.getText();
|
|
2265
|
+
const { scopes, context } = templateTypes(open, text);
|
|
2266
|
+
/**
|
|
2267
|
+
* Each check, with what it's for: a filter's input, a positional or a named argument. A wrong type is reported on
|
|
2268
|
+
* the value, and a named argument the function doesn't take on its name.
|
|
2269
|
+
*/
|
|
2270
|
+
const checks = [];
|
|
2271
|
+
for (const node of expressionsIn(scopes.ast.body)) {
|
|
2272
|
+
let kind;
|
|
2273
|
+
let name;
|
|
2274
|
+
if (node.type === "Call" && node.callee.type === "Name") {
|
|
2275
|
+
kind = "function";
|
|
2276
|
+
name = node.callee.name;
|
|
2277
|
+
if (context.macros.has(name)) continue;
|
|
2278
|
+
} else if (node.type === "Filter") {
|
|
2279
|
+
kind = "filter";
|
|
2280
|
+
name = node.name;
|
|
2281
|
+
} else continue;
|
|
2282
|
+
if ((kind === "function" ? FUNCTIONS : FILTERS)[name] && !isRegistered(open.project, kind, name)) continue;
|
|
2283
|
+
if (node.args.some((arg) => arg.type === "Spread")) continue;
|
|
2284
|
+
const parameters = parametersType(kind, name, true);
|
|
2285
|
+
[...node.type === "Filter" ? [node.input] : [], ...node.args.flatMap((arg) => arg.type === "Positional" ? [arg.value] : [])].forEach((value, index) => {
|
|
2286
|
+
checks.push({
|
|
2287
|
+
type: expressionType(value, context),
|
|
2288
|
+
parameters,
|
|
2289
|
+
index,
|
|
2290
|
+
span: value.span,
|
|
2291
|
+
callee: name,
|
|
2292
|
+
role: node.type === "Filter" && index === 0 ? "input" : "positional"
|
|
2293
|
+
});
|
|
2294
|
+
});
|
|
2295
|
+
for (const arg of node.args) {
|
|
2296
|
+
if (arg.type !== "Named") continue;
|
|
2297
|
+
checks.push({
|
|
2298
|
+
type: expressionType(arg.value, context),
|
|
2299
|
+
parameters,
|
|
2300
|
+
name: arg.name,
|
|
2301
|
+
span: arg.value.span,
|
|
2302
|
+
nameSpan: {
|
|
2303
|
+
start: arg.span.start,
|
|
2304
|
+
end: arg.span.start + arg.name.length
|
|
2305
|
+
},
|
|
2306
|
+
callee: name,
|
|
2307
|
+
role: "named"
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
return checkArguments(typescript, checks).flatMap((problem, index) => {
|
|
2312
|
+
const check = checks[index];
|
|
2313
|
+
if (!problem || !check) return [];
|
|
2314
|
+
if (problem.kind === "missing") {
|
|
2315
|
+
const option = "name" in check ? check.name : "";
|
|
2316
|
+
return [{
|
|
2317
|
+
...check.nameSpan ?? check.span,
|
|
2318
|
+
code: "unknown-argument",
|
|
2319
|
+
message: `\`${check.callee}\` doesn't take \`${option}\`.`
|
|
2320
|
+
}];
|
|
2321
|
+
}
|
|
2322
|
+
const expected = `\`${problem.expected}\``;
|
|
2323
|
+
const actual = `not \`${problem.actual}\``;
|
|
2324
|
+
const message = check.role === "input" ? `\`${check.callee}\` needs ${expected} before the \`|\`, ${actual}.` : check.role === "named" && "name" in check ? `\`${check.name}\` should be ${expected}, ${actual}.` : `This argument should be ${expected}, ${actual}.`;
|
|
2325
|
+
return [{
|
|
2326
|
+
...check.span,
|
|
2327
|
+
code: "argument-type",
|
|
2328
|
+
message
|
|
2329
|
+
}];
|
|
2330
|
+
});
|
|
2331
|
+
};
|
|
2332
|
+
//#endregion
|
|
2333
|
+
//#region src/server.ts
|
|
2334
|
+
/** Serves Kiln templates over a language server connection. */
|
|
2335
|
+
const startServer = (connection) => {
|
|
2336
|
+
const documents = new TextDocuments(TextDocument);
|
|
2337
|
+
let workspaceFolders = [];
|
|
2338
|
+
let canWatchFiles = false;
|
|
2339
|
+
/** Whether the client takes `LocationLink`s from go to definition. Plain locations lose the clicked range. */
|
|
2340
|
+
let definitionLinks = false;
|
|
2341
|
+
/** Open projects, by the path of their `kiln.json`, or their folder when they have none. */
|
|
2342
|
+
const projects = /* @__PURE__ */ new Map();
|
|
2343
|
+
connection.onInitialize((params) => {
|
|
2344
|
+
workspaceFolders = (params.workspaceFolders ?? []).map((folder) => folder.uri).concat(params.rootUri && !params.workspaceFolders ? [params.rootUri] : []).filter((uri) => uri.startsWith("file:")).map((uri) => fileURLToPath(uri));
|
|
2345
|
+
canWatchFiles = params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration === true;
|
|
2346
|
+
definitionLinks = params.capabilities.textDocument?.definition?.linkSupport === true;
|
|
2347
|
+
return {
|
|
2348
|
+
capabilities: {
|
|
2349
|
+
textDocumentSync: TextDocumentSyncKind.Incremental,
|
|
2350
|
+
completionProvider: { triggerCharacters: [
|
|
2351
|
+
"|",
|
|
2352
|
+
".",
|
|
2353
|
+
"%",
|
|
2354
|
+
"\"",
|
|
2355
|
+
"'",
|
|
2356
|
+
"/",
|
|
2357
|
+
" ",
|
|
2358
|
+
"{",
|
|
2359
|
+
",",
|
|
2360
|
+
"<",
|
|
2361
|
+
"="
|
|
2362
|
+
] },
|
|
2363
|
+
hoverProvider: true,
|
|
2364
|
+
signatureHelpProvider: {
|
|
2365
|
+
triggerCharacters: ["(", ","],
|
|
2366
|
+
retriggerCharacters: [":", " "]
|
|
2367
|
+
},
|
|
2368
|
+
definitionProvider: true,
|
|
2369
|
+
documentLinkProvider: { resolveProvider: false },
|
|
2370
|
+
documentSymbolProvider: true,
|
|
2371
|
+
documentFormattingProvider: true,
|
|
2372
|
+
documentRangeFormattingProvider: true
|
|
2373
|
+
},
|
|
2374
|
+
serverInfo: { name: "kiln-language-server" }
|
|
2375
|
+
};
|
|
2376
|
+
});
|
|
2377
|
+
connection.onInitialized(() => {
|
|
2378
|
+
if (!canWatchFiles) return;
|
|
2379
|
+
connection.client.register(DidChangeWatchedFilesNotification.type, { watchers: [
|
|
2380
|
+
{ globPattern: "**/*.kiln" },
|
|
2381
|
+
{ globPattern: "**/kiln.json" },
|
|
2382
|
+
{ globPattern: "**/*.{ts,tsx,mts,cts}" },
|
|
2383
|
+
{ globPattern: "**/tsconfig*.json" }
|
|
2384
|
+
] });
|
|
2385
|
+
});
|
|
2386
|
+
/** The project a file belongs to: the nearest `kiln.json` inside its workspace folder, or the folder itself. */
|
|
2387
|
+
const projectFor = async (path) => {
|
|
2388
|
+
const folder = workspaceFolders.filter((candidate) => {
|
|
2389
|
+
const name = relative(candidate, path);
|
|
2390
|
+
return name !== ".." && !name.startsWith(".." + sep) && !isAbsolute(name);
|
|
2391
|
+
}).sort((a, b) => b.length - a.length)[0] ?? null;
|
|
2392
|
+
const manifestPath = await findManifest(dirname(path), folder);
|
|
2393
|
+
const fallbackRoot = folder ?? dirname(path);
|
|
2394
|
+
const key = manifestPath ?? fallbackRoot;
|
|
2395
|
+
let project = projects.get(key);
|
|
2396
|
+
if (!project) {
|
|
2397
|
+
project = openProject(manifestPath, fallbackRoot, (message) => connection.window.showWarningMessage(message));
|
|
2398
|
+
projects.set(key, project);
|
|
2399
|
+
}
|
|
2400
|
+
return project;
|
|
2401
|
+
};
|
|
2402
|
+
const openTemplate = async (document) => {
|
|
2403
|
+
if (!document.uri.startsWith("file:")) return null;
|
|
2404
|
+
const path = fileURLToPath(document.uri);
|
|
2405
|
+
const project = await projectFor(path);
|
|
2406
|
+
return {
|
|
2407
|
+
document,
|
|
2408
|
+
project,
|
|
2409
|
+
name: nameOf(project, path)
|
|
2410
|
+
};
|
|
2411
|
+
};
|
|
2412
|
+
const pending = /* @__PURE__ */ new Map();
|
|
2413
|
+
/** Checks a template shortly after its last change, and publishes its diagnostics. */
|
|
2414
|
+
const scheduleCheck = (document) => {
|
|
2415
|
+
clearTimeout(pending.get(document.uri));
|
|
2416
|
+
pending.set(document.uri, setTimeout(() => {
|
|
2417
|
+
pending.delete(document.uri);
|
|
2418
|
+
check(document);
|
|
2419
|
+
}, 150));
|
|
2420
|
+
};
|
|
2421
|
+
const check = async (document) => {
|
|
2422
|
+
const open = await openTemplate(document);
|
|
2423
|
+
if (!open?.name) return;
|
|
2424
|
+
const { project, name } = open;
|
|
2425
|
+
const version = document.version;
|
|
2426
|
+
const diagnostics = await diagnose(project, name, document.getText()).catch(() => []);
|
|
2427
|
+
const warnings = await argumentWarnings(open).catch(() => []);
|
|
2428
|
+
if (documents.get(document.uri)?.version !== version) return;
|
|
2429
|
+
connection.sendDiagnostics({
|
|
2430
|
+
uri: document.uri,
|
|
2431
|
+
version,
|
|
2432
|
+
diagnostics: diagnostics.map((diagnostic) => ({
|
|
2433
|
+
range: {
|
|
2434
|
+
start: document.positionAt(diagnostic.start.offset),
|
|
2435
|
+
end: document.positionAt(diagnostic.end.offset)
|
|
2436
|
+
},
|
|
2437
|
+
severity: diagnostic.severity === "error" ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning,
|
|
2438
|
+
code: diagnostic.code,
|
|
2439
|
+
source: "kiln",
|
|
2440
|
+
message: diagnostic.message
|
|
2441
|
+
})).concat(warnings.map((warning) => ({
|
|
2442
|
+
range: {
|
|
2443
|
+
start: document.positionAt(warning.start),
|
|
2444
|
+
end: document.positionAt(warning.end)
|
|
2445
|
+
},
|
|
2446
|
+
severity: DiagnosticSeverity.Warning,
|
|
2447
|
+
code: warning.code,
|
|
2448
|
+
source: "kiln",
|
|
2449
|
+
message: warning.message
|
|
2450
|
+
})))
|
|
2451
|
+
});
|
|
2452
|
+
};
|
|
2453
|
+
/** Every open template in a project sees an edit to any of them, since one template's props can break another. */
|
|
2454
|
+
const checkProject = async (project) => {
|
|
2455
|
+
for (const document of documents.all()) if ((await openTemplate(document))?.project === project) scheduleCheck(document);
|
|
2456
|
+
};
|
|
2457
|
+
documents.onDidChangeContent(async ({ document }) => {
|
|
2458
|
+
const open = await openTemplate(document);
|
|
2459
|
+
if (!open?.name) return;
|
|
2460
|
+
open.project.overlay.set(open.name, document.getText());
|
|
2461
|
+
open.project.kiln.invalidate(open.name);
|
|
2462
|
+
await checkProject(open.project);
|
|
2463
|
+
});
|
|
2464
|
+
documents.onDidClose(async ({ document }) => {
|
|
2465
|
+
clearTimeout(pending.get(document.uri));
|
|
2466
|
+
connection.sendDiagnostics({
|
|
2467
|
+
uri: document.uri,
|
|
2468
|
+
diagnostics: []
|
|
2469
|
+
});
|
|
2470
|
+
const open = await openTemplate(document);
|
|
2471
|
+
if (!open?.name) return;
|
|
2472
|
+
open.project.overlay.delete(open.name);
|
|
2473
|
+
open.project.kiln.invalidate(open.name);
|
|
2474
|
+
await checkProject(open.project);
|
|
2475
|
+
});
|
|
2476
|
+
/** Closes every open project, so the next request opens them again from their settings. */
|
|
2477
|
+
const closeProjects = async () => {
|
|
2478
|
+
const open = [...projects.values()];
|
|
2479
|
+
projects.clear();
|
|
2480
|
+
for (const project of open) (await project).close();
|
|
2481
|
+
};
|
|
2482
|
+
connection.onShutdown(closeProjects);
|
|
2483
|
+
connection.onDidChangeWatchedFiles(async ({ changes }) => {
|
|
2484
|
+
const isSettings = (uri) => uri.endsWith("/kiln.json") || /\/tsconfig[^/]*\.json$/.test(uri);
|
|
2485
|
+
const typescriptChanges = changes.flatMap((change) => change.uri.startsWith("file:") && /\.[cm]?tsx?$/.test(change.uri) ? [{
|
|
2486
|
+
path: fileURLToPath(change.uri),
|
|
2487
|
+
kind: change.type === FileChangeType.Created ? "created" : change.type === FileChangeType.Deleted ? "deleted" : "changed"
|
|
2488
|
+
}] : []);
|
|
2489
|
+
if (typescriptChanges.length > 0) for (const project of projects.values()) (await project).typescriptChanged(typescriptChanges);
|
|
2490
|
+
if (changes.some((change) => isSettings(change.uri))) {
|
|
2491
|
+
await closeProjects();
|
|
2492
|
+
for (const document of documents.all()) {
|
|
2493
|
+
const open = await openTemplate(document);
|
|
2494
|
+
if (open?.name) {
|
|
2495
|
+
open.project.overlay.set(open.name, document.getText());
|
|
2496
|
+
open.project.kiln.invalidate(open.name);
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
} else for (const change of changes) {
|
|
2500
|
+
if (!change.uri.startsWith("file:") || !change.uri.endsWith(".kiln")) continue;
|
|
2501
|
+
const path = fileURLToPath(change.uri);
|
|
2502
|
+
const project = await projectFor(path);
|
|
2503
|
+
const name = nameOf(project, path);
|
|
2504
|
+
if (name) project.kiln.invalidate(name);
|
|
2505
|
+
}
|
|
2506
|
+
for (const document of documents.all()) scheduleCheck(document);
|
|
2507
|
+
});
|
|
2508
|
+
/** Runs a handler with the open template for a request, or returns `fallback` when there is none. */
|
|
2509
|
+
const withTemplate = (fallback, handler) => async (params) => {
|
|
2510
|
+
const document = documents.get(params.textDocument.uri);
|
|
2511
|
+
const open = document ? await openTemplate(document) : null;
|
|
2512
|
+
return open ? handler(open, params) : fallback;
|
|
2513
|
+
};
|
|
2514
|
+
connection.onCompletion(withTemplate([], (open, params) => complete(open, open.document.offsetAt(params.position))));
|
|
2515
|
+
connection.onHover(withTemplate(null, (open, params) => hover(open, open.document.offsetAt(params.position))));
|
|
2516
|
+
connection.onSignatureHelp(withTemplate(null, (open, params) => signatureHelp(open, open.document.offsetAt(params.position))));
|
|
2517
|
+
connection.onDefinition(withTemplate(null, async (open, params) => {
|
|
2518
|
+
const link = await definition(open, open.document.offsetAt(params.position));
|
|
2519
|
+
if (!link || definitionLinks) return link && [link];
|
|
2520
|
+
return {
|
|
2521
|
+
uri: link.targetUri,
|
|
2522
|
+
range: link.targetSelectionRange
|
|
2523
|
+
};
|
|
2524
|
+
}));
|
|
2525
|
+
connection.onDocumentLinks(withTemplate([], (open) => links(open)));
|
|
2526
|
+
connection.onDocumentSymbol(withTemplate([], (open) => symbols(open.document)));
|
|
2527
|
+
/** Formats a template, or part of one, as edits. Templates with syntax errors are left alone. */
|
|
2528
|
+
const formatTemplate = (open, options, range) => {
|
|
2529
|
+
const { document, project } = open;
|
|
2530
|
+
try {
|
|
2531
|
+
return project.format.formatEdits(document.getText(), {
|
|
2532
|
+
useTabs: !options.insertSpaces,
|
|
2533
|
+
tabWidth: options.tabSize,
|
|
2534
|
+
range
|
|
2535
|
+
}).map((edit) => ({
|
|
2536
|
+
range: {
|
|
2537
|
+
start: document.positionAt(edit.start),
|
|
2538
|
+
end: document.positionAt(edit.end)
|
|
2539
|
+
},
|
|
2540
|
+
newText: edit.text
|
|
2541
|
+
}));
|
|
2542
|
+
} catch (error) {
|
|
2543
|
+
if (error instanceof project.format.KilnFormatError) connection.window.showErrorMessage(error.message);
|
|
2544
|
+
return [];
|
|
2545
|
+
}
|
|
2546
|
+
};
|
|
2547
|
+
connection.onDocumentFormatting(withTemplate([], (open, params) => formatTemplate(open, params.options)));
|
|
2548
|
+
connection.onDocumentRangeFormatting(withTemplate([], (open, params) => formatTemplate(open, params.options, {
|
|
2549
|
+
start: open.document.offsetAt(params.range.start),
|
|
2550
|
+
end: open.document.offsetAt(params.range.end)
|
|
2551
|
+
})));
|
|
2552
|
+
documents.listen(connection);
|
|
2553
|
+
connection.listen();
|
|
2554
|
+
};
|
|
2555
|
+
//#endregion
|
|
2556
|
+
//#region src/main.ts
|
|
2557
|
+
startServer(createConnection(ProposedFeatures.all));
|
|
2558
|
+
//#endregion
|
|
2559
|
+
export {};
|
|
2560
|
+
|
|
2561
|
+
//# sourceMappingURL=main.js.map
|