miki-template 1.2.0 → 1.3.3
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/.github/release-notes/v1.3.1.md +55 -0
- package/CHANGELOG.md +72 -0
- package/README.md +43 -26
- package/assets/banner.png +0 -0
- package/benchmarks/stress.mjs +647 -0
- package/dir/base.html +23 -0
- package/dir/cmpnt.html +11 -0
- package/dir/footer.html +3 -0
- package/dir/home.html +80 -0
- package/dir/navbar.html +9 -0
- package/docs/api.md +20 -3
- package/docs/filters.md +301 -133
- package/docs/partialdef.md +30 -1
- package/docs/tags.md +63 -0
- package/docs/usage.md +50 -3
- package/eslint.config.mjs +9 -1
- package/ex.mjs +33 -0
- package/miki-template-extension/.github/workflows/ci.yml +116 -0
- package/miki-template-extension/.vscodeignore +7 -0
- package/miki-template-extension/CHANGELOG.md +99 -0
- package/miki-template-extension/README.md +244 -53
- package/miki-template-extension/extension.js +1013 -0
- package/miki-template-extension/icon.png +0 -0
- package/miki-template-extension/miki-template-1.7.1.vsix +0 -0
- package/miki-template-extension/package.json +244 -10
- package/miki-template-extension/snippets/miki-template.json +612 -72
- package/miki-template-extension/syntaxes/language-configuration.json +101 -13
- package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +270 -61
- package/miki-template-extension/tests/grammar-tests.json +162 -0
- package/miki-template-extension/tests/run-grammar-tests.js +82 -0
- package/package.json +7 -4
- package/scripts/build-vsix.js +129 -0
- package/scripts/build-vsix.ps1 +15 -0
- package/src/cache.js +41 -2
- package/src/context.js +9 -5
- package/src/context_processors.js +9 -2
- package/src/esm.mjs +12 -0
- package/src/filters.js +472 -24
- package/src/index.js +571 -85
- package/src/lexer.js +76 -54
- package/src/libraries.js +134 -3
- package/src/parser.js +22 -2
- package/src/security.js +4 -2
- package/src/tags/control.js +150 -21
- package/src/tags/extra.js +154 -0
- package/src/tags/i18n.js +49 -23
- package/src/tags/inheritance.js +142 -23
- package/src/tags/util.js +102 -24
- package/tests/esm.test.mjs +37 -2
- package/tests/filters.test.js +155 -0
- package/tests/integration/README.md +32 -0
- package/tests/integration/features.test.cjs +1681 -0
- package/tests/integration/features.test.mjs +1697 -0
- package/tests/integration/templates/base.miki +6 -0
- package/tests/integration/templates/child.miki +6 -0
- package/tests/integration/templates/index.html +17 -0
- package/tests/parser.test.js +5 -3
- package/tests/partialdef.test.js +40 -1
- package/tests/tags.test.js +30 -0
- package/miki-template-1.2.0.vsix +0 -0
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
const vscode = require('vscode');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
|
|
5
|
+
const TAGS = {
|
|
6
|
+
if: { doc: 'Conditionally renders content based on an expression.', syntax: '{% if condition %}' },
|
|
7
|
+
elif: { doc: 'Else-if condition in an if block.', syntax: '{% elif condition %}' },
|
|
8
|
+
else: { doc: 'Else block for if conditions.', syntax: '{% else %}' },
|
|
9
|
+
endif: { doc: 'End of an if block.', syntax: '{% endif %}' },
|
|
10
|
+
for: { doc: 'Iterates over arrays or objects.', syntax: '{% for item in items %}' },
|
|
11
|
+
empty: { doc: 'Content shown when loop has no items.', syntax: '{% empty %}' },
|
|
12
|
+
endfor: { doc: 'End of a for loop.', syntax: '{% endfor %}' },
|
|
13
|
+
with: { doc: 'Creates scoped aliases for variables.', syntax: '{% with var as alias %}' },
|
|
14
|
+
endwith: { doc: 'End of a with block.', syntax: '{% endwith %}' },
|
|
15
|
+
cycle: { doc: 'Outputs one of its arguments for each iteration.', syntax: "{% cycle 'val1' 'val2' %}" },
|
|
16
|
+
firstof: { doc: 'Outputs the first argument that evaluates to true.', syntax: '{% firstof var1 var2 "fallback" %}' },
|
|
17
|
+
comment: { doc: 'Block comment that is stripped from output.', syntax: '{% comment %} ... {% endcomment %}' },
|
|
18
|
+
endcomment: { doc: 'End of comment block.', syntax: '{% endcomment %}' },
|
|
19
|
+
verbatim: { doc: 'Prevents all tag/variable parsing inside.', syntax: '{% verbatim %} ... {% endverbatim %}' },
|
|
20
|
+
endverbatim: { doc: 'End of verbatim block.', syntax: '{% endverbatim %}' },
|
|
21
|
+
include: { doc: 'Includes another template file.', syntax: '{% include "template.html" %}' },
|
|
22
|
+
extends: { doc: 'Must be first tag - specifies parent template.', syntax: '{% extends "base.html" %}' },
|
|
23
|
+
block: { doc: 'Defines a replaceable section.', syntax: '{% block name %} ... {% endblock %}' },
|
|
24
|
+
endblock: { doc: 'End of a block.', syntax: '{% endblock %}' },
|
|
25
|
+
'block.super': { doc: 'Renders parent template block content.', syntax: '{{ block.super }}' },
|
|
26
|
+
partialdef: { doc: 'Defines a reusable fragment (miki-template).', syntax: '{% partialdef name %} ... {% endpartialdef %}' },
|
|
27
|
+
endpartialdef: { doc: 'End of partialdef block.', syntax: '{% endpartialdef %}' },
|
|
28
|
+
partial: { doc: 'Renders a previously defined partial.', syntax: '{% partial name %}' },
|
|
29
|
+
load: { doc: 'Loads additional filter libraries.', syntax: '{% load i18n %}' },
|
|
30
|
+
spaceless: { doc: 'Removes whitespace between HTML tags.', syntax: '{% spaceless %} ... {% endspaceless %}' },
|
|
31
|
+
endspaceless: { doc: 'End of spaceless block.', syntax: '{% endspaceless %}' },
|
|
32
|
+
autoescape: { doc: 'Controls HTML escaping.', syntax: '{% autoescape on %} ... {% endautoescape %}' },
|
|
33
|
+
endautoescape: { doc: 'End of autoescape block.', syntax: '{% endautoescape %}' },
|
|
34
|
+
filter: { doc: 'Applies a filter to block content.', syntax: '{% filter lower %} ... {% endfilter %}' },
|
|
35
|
+
endfilter: { doc: 'End of filter block.', syntax: '{% endfilter %}' },
|
|
36
|
+
templatetag: { doc: 'Outputs a template tag symbol.', syntax: '{% templatetag openblock %}' },
|
|
37
|
+
trans: { doc: 'Outputs a translated string.', syntax: '{% trans "Hello" %}' },
|
|
38
|
+
blocktrans: { doc: 'Translates a block of text.', syntax: '{% blocktrans %} ... {% endblocktrans %}' },
|
|
39
|
+
endblocktrans: { doc: 'End of blocktrans.', syntax: '{% endblocktrans %}' },
|
|
40
|
+
plural: { doc: 'Plural form in blocktrans.', syntax: '{% plural %}' },
|
|
41
|
+
language: { doc: 'Switches active language.', syntax: '{% language "fr" %} ... {% endlanguage %}' },
|
|
42
|
+
endlanguage: { doc: 'End of language block.', syntax: '{% endlanguage %}' },
|
|
43
|
+
regroup: { doc: 'Regroups a list by a common attribute.', syntax: '{% regroup items by attr as groups %}' },
|
|
44
|
+
widthratio: { doc: 'Calculates proportional width.', syntax: '{% widthratio value max max_width %}' },
|
|
45
|
+
debug: { doc: 'Dumps template context.', syntax: '{% debug %}' },
|
|
46
|
+
csrf_token: { doc: 'Outputs CSRF token hidden input.', syntax: '{% csrf_token %}' },
|
|
47
|
+
csp_nonce_attr: { doc: 'Outputs CSP nonce attribute.', syntax: '{% csp_nonce_attr %}' },
|
|
48
|
+
static: { doc: 'Generates URL for static asset.', syntax: '{% static "css/app.css" %}' },
|
|
49
|
+
url: { doc: 'Generates URL for named route.', syntax: '{% url "route-name" %}' },
|
|
50
|
+
cache: { doc: 'Caches block content (miki-template).', syntax: '{% cache timeout key %} ... {% endcache %}' },
|
|
51
|
+
endcache: { doc: 'End of cache block.', syntax: '{% endcache %}' },
|
|
52
|
+
addtoblock: { doc: 'Appends content to a block (miki-template).', syntax: '{% addtoblock css %} ... {% endaddtoblock %}' },
|
|
53
|
+
endaddtoblock: { doc: 'End of addtoblock.', syntax: '{% endaddtoblock %}' },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const FILTERS = {
|
|
57
|
+
upper: { doc: 'Converts to UPPERCASE.', syntax: '{{ value|upper }}', args: [] },
|
|
58
|
+
lower: { doc: 'Converts to lowercase.', syntax: '{{ value|lower }}', args: [] },
|
|
59
|
+
title: { doc: 'Converts to Title Case.', syntax: '{{ value|title }}', args: [] },
|
|
60
|
+
capfirst: { doc: 'Capitalizes first character.', syntax: '{{ value|capfirst }}', args: [] },
|
|
61
|
+
slugify: { doc: 'Converts to URL-safe slug.', syntax: '{{ value|slugify }}', args: [] },
|
|
62
|
+
wordcount: { doc: 'Returns word count.', syntax: '{{ text|wordcount }}', args: [] },
|
|
63
|
+
striptags: { doc: 'Removes HTML tags.', syntax: '{{ html|striptags }}', args: [] },
|
|
64
|
+
truncatewords: { doc: 'Truncates to N words.', syntax: '{{ text|truncatewords:10 }}', args: ['n'] },
|
|
65
|
+
truncatechars: { doc: 'Truncates to N characters.', syntax: '{{ text|truncatechars:100 }}', args: ['n'] },
|
|
66
|
+
linebreaks: { doc: 'Converts newlines to HTML paragraphs.', syntax: '{{ text|linebreaks }}', args: [] },
|
|
67
|
+
linebreaksbr: { doc: 'Converts newlines to <br>.', syntax: '{{ text|linebreaksbr }}', args: [] },
|
|
68
|
+
cut: { doc: 'Removes occurrences of value.', syntax: '{{ value|cut:" " }}', args: ['value'] },
|
|
69
|
+
addslashes: { doc: 'Adds backslashes before quotes.', syntax: "{{ value|addslashes }}", args: [] },
|
|
70
|
+
removetags: { doc: 'Removes specific HTML tags.', syntax: '{{ html|removetags:"p,div" }}', args: ['tags'] },
|
|
71
|
+
safe: { doc: 'Marks value as HTML-safe.', syntax: '{{ html|safe }}', args: [] },
|
|
72
|
+
escape: { doc: 'Escapes HTML entities.', syntax: '{{ value|escape }}', args: [] },
|
|
73
|
+
escapejs: { doc: 'Escapes for JavaScript.', syntax: '{{ value|escapejs }}', args: [] },
|
|
74
|
+
urlencode: { doc: 'URL encodes the value.', syntax: '{{ value|urlencode }}', args: [] },
|
|
75
|
+
escapeurl: { doc: 'Full URL encoding.', syntax: '{{ url|escapeurl }}', args: [] },
|
|
76
|
+
stringformat: { doc: 'Python-style format.', syntax: '{{ value|stringformat:"s" }}', args: ['fmt'] },
|
|
77
|
+
center: { doc: 'Centers text in field.', syntax: '{{ value|center:10 }}', args: ['width'] },
|
|
78
|
+
ljust: { doc: 'Left justifies text.', syntax: '{{ value|ljust:10 }}', args: ['width'] },
|
|
79
|
+
rjust: { doc: 'Right justifies text.', syntax: '{{ value|rjust:10 }}', args: ['width'] },
|
|
80
|
+
length: { doc: 'Returns length.', syntax: '{{ value|length }}', args: [] },
|
|
81
|
+
length_is: { doc: 'Checks if length equals N.', syntax: '{{ value|length_is:5 }}', args: ['n'] },
|
|
82
|
+
join: { doc: 'Joins array with separator.', syntax: '{{ list|join:", " }}', args: ['separator'] },
|
|
83
|
+
slice: { doc: 'Slices array/string.', syntax: "{{ value|slice:'0:3' }}", args: ['start:end'] },
|
|
84
|
+
first: { doc: 'Returns first element.', syntax: '{{ list|first }}', args: [] },
|
|
85
|
+
last: { doc: 'Returns last element.', syntax: '{{ list|last }}', args: [] },
|
|
86
|
+
dictsort: { doc: 'Sorts by key (ascending).', syntax: '{{ list|dictsort:"name" }}', args: ['key'] },
|
|
87
|
+
dictsortreversed: { doc: 'Sorts by key (descending).', syntax: '{{ list|dictsortreversed:"name" }}', args: ['key'] },
|
|
88
|
+
default: { doc: 'Fallback if falsy.', syntax: '{{ value|default:"fallback" }}', args: ['fallback'] },
|
|
89
|
+
default_if_none: { doc: 'Fallback if None/undefined.', syntax: '{{ value|default_if_none:"fallback" }}', args: ['fallback'] },
|
|
90
|
+
firstof: { doc: 'First truthy value.', syntax: '{{ val1|firstof:val2:val3 }}', args: ['val2', 'val3'] },
|
|
91
|
+
date: { doc: 'Formats date.', syntax: '{{ date|date:"Y-m-d" }}', args: ['format'] },
|
|
92
|
+
time: { doc: 'Formats time.', syntax: '{{ date|time:"H:i" }}', args: ['format'] },
|
|
93
|
+
strftime: { doc: 'Format with date-fns.', syntax: '{{ date|strftime:"PPpp" }}', args: ['format'] },
|
|
94
|
+
timesince: { doc: 'Human-readable time ago.', syntax: '{{ date|timesince }}', args: ['other_date?'] },
|
|
95
|
+
timeuntil: { doc: 'Human-readable time until.', syntax: '{{ date|timeuntil }}', args: ['other_date?'] },
|
|
96
|
+
add: { doc: 'Adds N to value.', syntax: '{{ value|add:5 }}', args: ['n'] },
|
|
97
|
+
divisibleby: { doc: 'Checks divisibility.', syntax: '{{ value|divisibleby:2 }}', args: ['n'] },
|
|
98
|
+
floatformat: { doc: 'Formats decimal places.', syntax: '{{ value|floatformat:2 }}', args: ['decimals?'] },
|
|
99
|
+
yesno: { doc: 'Maps boolean to strings.', syntax: '{{ value|yesno:"yes,no,maybe" }}', args: ['yes,no,maybe'] },
|
|
100
|
+
pluralize: { doc: 'Returns plural suffix.', syntax: '{{ count|pluralize }}', args: ['suffix?'] },
|
|
101
|
+
filesizeformat: { doc: 'Human-readable file size.', syntax: '{{ bytes|filesizeformat }}', args: [] },
|
|
102
|
+
trans: { doc: 'Translates string.', syntax: '{{ "Hello"|trans }}', args: ['fallback?'] },
|
|
103
|
+
regroup: { doc: 'Groups list by attribute.', syntax: '{{ list|regroup:"category" }}', args: ['key'] },
|
|
104
|
+
intcomma: { doc: 'Adds commas to integer.', syntax: '{{ number|intcomma }}', args: [] },
|
|
105
|
+
intword: { doc: 'Large number to word.', syntax: '{{ number|intword }}', args: [] },
|
|
106
|
+
apnumber: { doc: '1→one, 2→two.', syntax: '{{ number|apnumber }}', args: [] },
|
|
107
|
+
ordinal: { doc: '1→1st, 2→2nd.', syntax: '{{ number|ordinal }}', args: [] },
|
|
108
|
+
naturalday: { doc: '"yesterday", "today".', syntax: '{{ date|naturalday }}', args: [] },
|
|
109
|
+
json_script: { doc: 'JSON script tag.', syntax: "{{ data|json_script:'id' }}", args: ['id'] },
|
|
110
|
+
get_digit: { doc: 'Get digit by position.', syntax: '{{ number|get_digit:1 }}', args: ['position'] },
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const FORLOOP_VARS = [
|
|
114
|
+
{ name: 'forloop.counter', doc: '1-indexed loop counter' },
|
|
115
|
+
{ name: 'forloop.counter0', doc: '0-indexed loop counter' },
|
|
116
|
+
{ name: 'forloop.revcounter', doc: 'Remaining iterations (1-indexed)' },
|
|
117
|
+
{ name: 'forloop.revcounter0', doc: 'Remaining iterations (0-indexed)' },
|
|
118
|
+
{ name: 'forloop.first', doc: 'True if first iteration' },
|
|
119
|
+
{ name: 'forloop.last', doc: 'True if last iteration' },
|
|
120
|
+
{ name: 'forloop.parentloop', doc: 'Reference to parent loop' },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
const COLOR_REGEX = /(?:#[0-9A-Fa-f]{3,8}|rgba?\s*\([^)]+\)|hsla?\s*\([^)]+\))/g;
|
|
124
|
+
|
|
125
|
+
let customFilters = [];
|
|
126
|
+
let customTags = [];
|
|
127
|
+
let diagnosticCollection;
|
|
128
|
+
let colorDecorationType;
|
|
129
|
+
let bracketHighlightDecorations = new Map();
|
|
130
|
+
|
|
131
|
+
function debounce(fn, delay) {
|
|
132
|
+
let timer;
|
|
133
|
+
return (...args) => {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
timer = setTimeout(() => fn(...args), delay);
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function createCompletionItem(name, info, kind) {
|
|
140
|
+
const item = new vscode.CompletionItem(name, kind);
|
|
141
|
+
item.detail = info.syntax;
|
|
142
|
+
item.documentation = new vscode.MarkdownString(info.doc);
|
|
143
|
+
return item;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const TAG_COMPLETIONS = Object.entries(TAGS).map(([name]) => createCompletionItem(name, TAGS[name], vscode.CompletionItemKind.Keyword));
|
|
147
|
+
const FILTER_COMPLETIONS = Object.entries(FILTERS).map(([name]) => createCompletionItem(name, FILTERS[name], vscode.CompletionItemKind.Function));
|
|
148
|
+
const FORLOOP_COMPLETIONS = FORLOOP_VARS.map(v => {
|
|
149
|
+
const item = new vscode.CompletionItem(v.name, vscode.CompletionItemKind.Variable);
|
|
150
|
+
item.detail = v.name;
|
|
151
|
+
item.documentation = v.doc;
|
|
152
|
+
return item;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
function isInsideForLoop(document, position) {
|
|
156
|
+
const textBefore = document.getText(new vscode.Range(0, 0, position.line, position.character));
|
|
157
|
+
const forMatch = textBefore.match(/\{%-?\s*for\s+\S+\s+in\s+[^\}]*$/);
|
|
158
|
+
const endforMatch = textBefore.match(/\{%-?\s*endfor\s/);
|
|
159
|
+
if (forMatch && (!endforMatch || forMatch.index > endforMatch.index)) {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function scanForCustomTagsAndFilters(workspaceFolder) {
|
|
166
|
+
if (!workspaceFolder) return;
|
|
167
|
+
|
|
168
|
+
const configFiles = [
|
|
169
|
+
path.join(workspaceFolder, 'miki-template.config.js'),
|
|
170
|
+
path.join(workspaceFolder, 'miki-template.config.json'),
|
|
171
|
+
path.join(workspaceFolder, '.mikirc'),
|
|
172
|
+
path.join(workspaceFolder, 'package.json'),
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
for (const configFile of configFiles) {
|
|
176
|
+
try {
|
|
177
|
+
if (fs.existsSync(configFile)) {
|
|
178
|
+
const content = fs.readFileSync(configFile, 'utf8');
|
|
179
|
+
if (configFile.endsWith('.json')) {
|
|
180
|
+
const config = JSON.parse(content);
|
|
181
|
+
if (config.filters) customFilters = [...customFilters, ...config.filters];
|
|
182
|
+
if (config.tags) customTags = [...customTags, ...config.tags];
|
|
183
|
+
if (config.mikiTemplate && config.mikiTemplate.filters) {
|
|
184
|
+
customFilters = [...customFilters, ...config.mikiTemplate.filters];
|
|
185
|
+
}
|
|
186
|
+
} else if (configFile.endsWith('.js')) {
|
|
187
|
+
const match = content.match(/registerFilter\s*\(\s*['"](\w+)['"]/g);
|
|
188
|
+
if (match) {
|
|
189
|
+
match.forEach(m => {
|
|
190
|
+
const name = m.match(/['"](\w+)['"]/)[1];
|
|
191
|
+
if (!customFilters.includes(name)) customFilters.push(name);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} catch (e) {}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function getAllReferences(word, workspaceFolder) {
|
|
201
|
+
const references = [];
|
|
202
|
+
if (!workspaceFolder) return references;
|
|
203
|
+
|
|
204
|
+
function searchInFolder(folder) {
|
|
205
|
+
try {
|
|
206
|
+
const entries = fs.readdirSync(folder, { withFileTypes: true });
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
const fullPath = path.join(folder, entry.name);
|
|
209
|
+
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
210
|
+
searchInFolder(fullPath);
|
|
211
|
+
} else if (entry.isFile() && /\.(miki|miki-template|django|dj|html|tpl)$/.test(entry.name)) {
|
|
212
|
+
try {
|
|
213
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
214
|
+
const lines = content.split('\n');
|
|
215
|
+
lines.forEach((line, idx) => {
|
|
216
|
+
if (line.includes(word)) {
|
|
217
|
+
references.push(new vscode.Location(
|
|
218
|
+
vscode.Uri.file(fullPath),
|
|
219
|
+
new vscode.Range(idx, 0, idx, line.length)
|
|
220
|
+
));
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
} catch (e) {}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch (e) {}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
searchInFolder(workspaceFolder.uri.fsPath);
|
|
230
|
+
return references;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function findTemplateFiles(workspaceFolder) {
|
|
234
|
+
const results = [];
|
|
235
|
+
if (!workspaceFolder) return results;
|
|
236
|
+
|
|
237
|
+
function search(folder) {
|
|
238
|
+
try {
|
|
239
|
+
const entries = fs.readdirSync(folder, { withFileTypes: true });
|
|
240
|
+
for (const entry of entries) {
|
|
241
|
+
const fullPath = path.join(folder, entry.name);
|
|
242
|
+
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
243
|
+
search(fullPath);
|
|
244
|
+
} else if (entry.isFile() && /\.(html|miki|miki-template|django|dj|tpl)$/.test(entry.name)) {
|
|
245
|
+
const relativePath = path.relative(workspaceFolder.uri.fsPath, fullPath).replace(/\\/g, '/');
|
|
246
|
+
results.push({
|
|
247
|
+
label: entry.name,
|
|
248
|
+
detail: relativePath,
|
|
249
|
+
fsPath: fullPath,
|
|
250
|
+
relativePath: relativePath
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} catch (e) {}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
search(workspaceFolder.uri.fsPath);
|
|
258
|
+
return results.sort((a, b) => a.label.localeCompare(b.label));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function activate(context) {
|
|
262
|
+
const config = vscode.workspace.getConfiguration('miki-template');
|
|
263
|
+
|
|
264
|
+
diagnosticCollection = vscode.languages.createDiagnosticCollection('miki-template');
|
|
265
|
+
|
|
266
|
+
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
|
267
|
+
scanForCustomTagsAndFilters(workspaceFolder);
|
|
268
|
+
|
|
269
|
+
const customFilterCompletions = customFilters.map(name => {
|
|
270
|
+
const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Function);
|
|
271
|
+
item.detail = `{{ value|${name} }}`;
|
|
272
|
+
item.documentation = `Custom filter: ${name}`;
|
|
273
|
+
return item;
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const customTagCompletions = customTags.map(name => {
|
|
277
|
+
const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Keyword);
|
|
278
|
+
item.detail = `{% ${name} %}`;
|
|
279
|
+
item.documentation = `Custom tag: ${name}`;
|
|
280
|
+
return item;
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Completion Provider with Path Completions
|
|
284
|
+
const completionProvider = vscode.languages.registerCompletionItemProvider(
|
|
285
|
+
['miki-template', 'django-html'],
|
|
286
|
+
{
|
|
287
|
+
provideCompletionItems(document, position) {
|
|
288
|
+
if (!config.get('enableCompletions', true)) return [];
|
|
289
|
+
|
|
290
|
+
const line = document.lineAt(position).text;
|
|
291
|
+
const beforeCursor = line.substring(0, position.character);
|
|
292
|
+
const allCompletions = [];
|
|
293
|
+
|
|
294
|
+
if (beforeCursor.match(/\{%\s*$/)) {
|
|
295
|
+
allCompletions.push(...TAG_COMPLETIONS, ...customTagCompletions);
|
|
296
|
+
return allCompletions;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (beforeCursor.match(/\{%\s*load\s+/) && !beforeCursor.match(/\{%\s*load\s+\S+\s+\S+/)) {
|
|
300
|
+
return ['i18n', 'humanize', 'cache', 'lorem'].map(
|
|
301
|
+
lib => new vscode.CompletionItem(lib, vscode.CompletionItemKind.Module)
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Path completions for include "..." and extends "..."
|
|
306
|
+
const pathMatch = beforeCursor.match(/\{%-?\s*(include|extends)\s+["']?([\w/.-]*)$/);
|
|
307
|
+
if (pathMatch) {
|
|
308
|
+
const files = findTemplateFiles(workspaceFolder);
|
|
309
|
+
const partial = pathMatch[2] || '';
|
|
310
|
+
const filtered = files.filter(f =>
|
|
311
|
+
f.label.toLowerCase().includes(partial.toLowerCase()) ||
|
|
312
|
+
f.relativePath.toLowerCase().includes(partial.toLowerCase())
|
|
313
|
+
);
|
|
314
|
+
return filtered.map(f => {
|
|
315
|
+
const item = new vscode.CompletionItem(f.label, vscode.CompletionItemKind.File);
|
|
316
|
+
item.detail = f.relativePath;
|
|
317
|
+
item.insertText = `"${f.relativePath}" `;
|
|
318
|
+
return item;
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (beforeCursor.match(/\{\{[^|]*$/)) {
|
|
323
|
+
allCompletions.push(...FILTER_COMPLETIONS, ...customFilterCompletions);
|
|
324
|
+
if (isInsideForLoop(document, position)) {
|
|
325
|
+
allCompletions.push(...FORLOOP_COMPLETIONS);
|
|
326
|
+
}
|
|
327
|
+
return allCompletions;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (beforeCursor.match(/\|\s*$/)) {
|
|
331
|
+
allCompletions.push(...FILTER_COMPLETIONS, ...customFilterCompletions);
|
|
332
|
+
return allCompletions;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return [];
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
'{', '%', '|', ' ', '"', "'"
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
// Hover Provider
|
|
342
|
+
const hoverProvider = vscode.languages.registerHoverProvider(
|
|
343
|
+
['miki-template', 'django-html'],
|
|
344
|
+
{
|
|
345
|
+
provideHover(document, position) {
|
|
346
|
+
if (!config.get('enableHover', true)) return null;
|
|
347
|
+
|
|
348
|
+
const word = document.getText(document.getWordRangeAtPosition(position));
|
|
349
|
+
|
|
350
|
+
if (TAGS[word]) {
|
|
351
|
+
const info = TAGS[word];
|
|
352
|
+
return new vscode.Hover(new vscode.MarkdownString(`**\\${word}**\n\n${info.doc}\n\n\`\`\`django\n${info.syntax}\n\`\`\``));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (FILTERS[word] || customFilters.includes(word)) {
|
|
356
|
+
const info = FILTERS[word] || { doc: 'Custom filter', syntax: `{{ value|${word} }}` };
|
|
357
|
+
const argsDoc = info.args && info.args.length > 0 ? `\n\n**Arguments:** \`${info.args.join('`, `')}\`` : '';
|
|
358
|
+
return new vscode.Hover(new vscode.MarkdownString(`**${word}**\n\n${info.doc}${argsDoc}\n\n\`\`\`django\n${info.syntax}\n\`\`\``));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Hover for block.super
|
|
362
|
+
const wordRange = document.getWordRangeAtPosition(position);
|
|
363
|
+
if (wordRange) {
|
|
364
|
+
const wordAtCursor = document.getText(wordRange);
|
|
365
|
+
if (wordAtCursor === 'block' || wordAtCursor === 'super') {
|
|
366
|
+
const line = document.lineAt(position).text;
|
|
367
|
+
const lineUntilCursor = line.substring(0, position.character);
|
|
368
|
+
if (lineUntilCursor.includes('block.super') || lineUntilCursor.includes('{{ block.')) {
|
|
369
|
+
return new vscode.Hover(new vscode.MarkdownString(`**{{ block.super }}**\n\nRenders the parent template's block content. Use inside a \`{% block %}\` to include parent content.`));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
// Semantic Token Provider
|
|
380
|
+
const tokenTypesSemantic = ['tag', 'variable', 'filter', 'comment', 'string', 'operator'];
|
|
381
|
+
const semanticTokensProvider = vscode.languages.registerDocumentSemanticTokensProvider(
|
|
382
|
+
['miki-template', 'django-html'],
|
|
383
|
+
{
|
|
384
|
+
provideDocumentSemanticTokens(document) {
|
|
385
|
+
const builder = new vscode.SemanticTokensBuilder();
|
|
386
|
+
const text = document.getText();
|
|
387
|
+
|
|
388
|
+
const tagRegex = /\{%-?\s*\w+/g;
|
|
389
|
+
const varRegex = /\{\{[^}]*\}\}/g;
|
|
390
|
+
const commentRegex = /\{#[^}]*#\}/g;
|
|
391
|
+
|
|
392
|
+
let match;
|
|
393
|
+
|
|
394
|
+
while ((match = tagRegex.exec(text)) !== null) {
|
|
395
|
+
const startPos = document.positionAt(match.index);
|
|
396
|
+
builder.push(startPos.line, startPos.character, match[0].length, 'entity.name.tag', 0);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
while ((match = varRegex.exec(text)) !== null) {
|
|
400
|
+
const startPos = document.positionAt(match.index);
|
|
401
|
+
builder.push(startPos.line, startPos.character, match[0].length, 'variable', 0);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
while ((match = commentRegex.exec(text)) !== null) {
|
|
405
|
+
const startPos = document.positionAt(match.index);
|
|
406
|
+
builder.push(startPos.line, startPos.character, match[0].length, 'comment', 0);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return builder.build();
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
new vscode.SemanticTokensLegend(tokenTypesSemantic, [])
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
// Inlay Hints Provider
|
|
416
|
+
const inlayHintsProvider = vscode.languages.registerInlayHintsProvider(
|
|
417
|
+
['miki-template', 'django-html'],
|
|
418
|
+
{
|
|
419
|
+
provideInlayHints(document, range) {
|
|
420
|
+
if (!config.get('enableInlayHints', true)) return [];
|
|
421
|
+
|
|
422
|
+
const hints = [];
|
|
423
|
+
const text = document.getText(range);
|
|
424
|
+
const filterArgRegex = /\|(\w+)(?::(["']?)(\w+)\2)?/g;
|
|
425
|
+
let match;
|
|
426
|
+
|
|
427
|
+
while ((match = filterArgRegex.exec(text)) !== null) {
|
|
428
|
+
const filterName = match[1];
|
|
429
|
+
if (FILTERS[filterName] && FILTERS[filterName].args && FILTERS[filterName].args.length > 0) {
|
|
430
|
+
const pos = document.positionAt(match.index);
|
|
431
|
+
hints.push(new vscode.InlayHint(`${filterName}:`, vscode.InlayHintKind.Parameter));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return hints;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
// Definition Provider
|
|
441
|
+
const definitionProvider = vscode.languages.registerDefinitionProvider(
|
|
442
|
+
['miki-template', 'django-html'],
|
|
443
|
+
{
|
|
444
|
+
provideDefinition(document, position) {
|
|
445
|
+
const word = document.getText(document.getWordRangeAtPosition(position));
|
|
446
|
+
if (!['include', 'extends', 'block', 'partial', 'partialdef'].includes(word)) return null;
|
|
447
|
+
|
|
448
|
+
const line = document.lineAt(position).text;
|
|
449
|
+
const fileMatch = line.match(/\{%-?\s*(?:include|extends|block|partial|partialdef)\s+["']([^"']+)["']/);
|
|
450
|
+
if (fileMatch) {
|
|
451
|
+
const currentDir = path.dirname(document.uri.fsPath);
|
|
452
|
+
const fileName = fileMatch[1];
|
|
453
|
+
const paths = [
|
|
454
|
+
path.join(currentDir, fileName),
|
|
455
|
+
path.join(currentDir, fileName.replace(/^\//, '')),
|
|
456
|
+
path.join(currentDir, '..', fileName),
|
|
457
|
+
path.join(currentDir, 'templates', fileName),
|
|
458
|
+
];
|
|
459
|
+
|
|
460
|
+
for (const fsPath of paths) {
|
|
461
|
+
try {
|
|
462
|
+
if (fs.existsSync(fsPath)) {
|
|
463
|
+
return new vscode.Location(vscode.Uri.file(fsPath), new vscode.Position(0, 0));
|
|
464
|
+
}
|
|
465
|
+
} catch (e) {}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
// Rename Provider
|
|
474
|
+
const renameProvider = vscode.languages.registerRenameProvider(
|
|
475
|
+
['miki-template', 'django-html'],
|
|
476
|
+
{
|
|
477
|
+
provideRenameEdits(document, position, newName) {
|
|
478
|
+
const line = document.lineAt(position).text;
|
|
479
|
+
|
|
480
|
+
const blockMatch = line.match(/\{%-?\s*block\s+(\w+)/);
|
|
481
|
+
if (!blockMatch) return null;
|
|
482
|
+
|
|
483
|
+
const oldBlockName = blockMatch[1];
|
|
484
|
+
const workspaceEdit = new vscode.WorkspaceEdit();
|
|
485
|
+
|
|
486
|
+
const fullText = document.getText();
|
|
487
|
+
const blockDefRegex = new RegExp(`\\{%-?\\s*block\\s+${oldBlockName}\\b`, 'g');
|
|
488
|
+
const blockSuperRegex = new RegExp(`\\{\\{[^}]*block\\.${oldBlockName}[^}]*\\}\\}`, 'g');
|
|
489
|
+
|
|
490
|
+
let match;
|
|
491
|
+
while ((match = blockDefRegex.exec(fullText)) !== null) {
|
|
492
|
+
const pos = document.positionAt(match.index);
|
|
493
|
+
workspaceEdit.replace(document.uri, new vscode.Range(pos, pos.translate(0, oldBlockName.length)), newName);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
while ((match = blockSuperRegex.exec(fullText)) !== null) {
|
|
497
|
+
const pos = document.positionAt(match.index);
|
|
498
|
+
const text = match[0];
|
|
499
|
+
workspaceEdit.replace(document.uri, new vscode.Range(pos, pos.translate(0, text.length)), text.replace(new RegExp(`block\\.${oldBlockName}`, 'g'), `block.${newName}`));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (workspaceFolder) {
|
|
503
|
+
const otherEdits = getBlockRenameEdits(oldBlockName, newName, workspaceFolder, document.uri);
|
|
504
|
+
for (const [uri, edits] of otherEdits.entries()) {
|
|
505
|
+
for (const edit of edits) {
|
|
506
|
+
workspaceEdit.replace(uri, edit.range, edit.newText);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return workspaceEdit;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
function getBlockRenameEdits(oldBlockName, newBlockName, folder, currentDocUri) {
|
|
517
|
+
const fileEditsMap = new Map();
|
|
518
|
+
|
|
519
|
+
function search(dir) {
|
|
520
|
+
try {
|
|
521
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
522
|
+
for (const entry of entries) {
|
|
523
|
+
const fullPath = path.join(dir, entry.name);
|
|
524
|
+
if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
525
|
+
search(fullPath);
|
|
526
|
+
} else if (entry.isFile() && /\.(miki|miki-template|django|dj|html|tpl)$/.test(entry.name)) {
|
|
527
|
+
const uri = vscode.Uri.file(fullPath);
|
|
528
|
+
if (uri.toString() === currentDocUri.toString()) continue;
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
532
|
+
if (content.includes(`block.${oldBlockName}`) || content.includes(`{% block ${oldBlockName}`)) {
|
|
533
|
+
const edits = [];
|
|
534
|
+
const blockSuperRegex = new RegExp(`block\\.${oldBlockName}`, 'g');
|
|
535
|
+
const blockDefRegex = new RegExp(`\\{%-?\\s*block\\s+${oldBlockName}\\b`, 'g');
|
|
536
|
+
|
|
537
|
+
let match;
|
|
538
|
+
|
|
539
|
+
while ((match = blockSuperRegex.exec(content)) !== null) {
|
|
540
|
+
const pos = new vscode.Position(0, 0).translate(0, match.index);
|
|
541
|
+
edits.push(new vscode.TextEdit(new vscode.Range(pos, pos.translate(0, match[0].length)), `block.${newBlockName}`));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
while ((match = blockDefRegex.exec(content)) !== null) {
|
|
545
|
+
const pos = new vscode.Position(0, 0).translate(0, match.index);
|
|
546
|
+
edits.push(new vscode.TextEdit(new vscode.Range(pos, pos.translate(0, match[0].length)), `{% block ${newBlockName}`));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (edits.length > 0) {
|
|
550
|
+
fileEditsMap.set(uri, edits);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
} catch (e) {}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
} catch (e) {}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
search(folder.uri.fsPath);
|
|
560
|
+
return fileEditsMap;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Document Symbol Provider
|
|
564
|
+
const symbolProvider = vscode.languages.registerDocumentSymbolProvider(
|
|
565
|
+
['miki-template', 'django-html'],
|
|
566
|
+
{
|
|
567
|
+
provideDocumentSymbols(document) {
|
|
568
|
+
const symbols = [];
|
|
569
|
+
const lines = document.getText().split('\n');
|
|
570
|
+
|
|
571
|
+
lines.forEach((line, index) => {
|
|
572
|
+
const range = new vscode.Range(index, 0, index, line.length);
|
|
573
|
+
|
|
574
|
+
const blockMatch = line.match(/\{%-?\s*block\s+(\w+)/);
|
|
575
|
+
if (blockMatch) symbols.push(new vscode.SymbolInformation(blockMatch[1], vscode.SymbolKind.Method, range, document.uri));
|
|
576
|
+
|
|
577
|
+
const extendsMatch = line.match(/\{%-?\s*extends\s+["']([^"']+)["']/);
|
|
578
|
+
if (extendsMatch) symbols.push(new vscode.SymbolInformation(`↳ ${extendsMatch[1]}`, vscode.SymbolKind.Class, range, document.uri));
|
|
579
|
+
|
|
580
|
+
const includeMatch = line.match(/\{%-?\s*include\s+["']([^"']+)["']/);
|
|
581
|
+
if (includeMatch) symbols.push(new vscode.SymbolInformation(`⊂ ${includeMatch[1]}`, vscode.SymbolKind.Reference, range, document.uri));
|
|
582
|
+
|
|
583
|
+
const partialdefMatch = line.match(/\{%-?\s*partialdef\s+(\w+)/);
|
|
584
|
+
if (partialdefMatch) symbols.push(new vscode.SymbolInformation(`§ ${partialdefMatch[1]}`, vscode.SymbolKind.Function, range, document.uri));
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
return symbols;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
// Selection Range Provider
|
|
593
|
+
const selectionRangeProvider = vscode.languages.registerSelectionRangeProvider(
|
|
594
|
+
['miki-template', 'django-html'],
|
|
595
|
+
{
|
|
596
|
+
provideSelectionRanges(document, positions) {
|
|
597
|
+
const results = [];
|
|
598
|
+
for (const pos of positions) {
|
|
599
|
+
const ranges = [];
|
|
600
|
+
const line = document.lineAt(pos.line).text;
|
|
601
|
+
|
|
602
|
+
const tagMatch = line.match(/\{%-?\s*(\w+)[^}]*%\}[\s\S]*\{%-?\s*end\1\s*%/);
|
|
603
|
+
if (tagMatch) {
|
|
604
|
+
const startIdx = line.indexOf(tagMatch[0]);
|
|
605
|
+
const endIdx = startIdx + tagMatch[0].length;
|
|
606
|
+
ranges.push(new vscode.SelectionRange(new vscode.Range(pos.line, startIdx, pos.line, endIdx)));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const varMatch = line.match(/\{\{[\s\S]*?\}\}/);
|
|
610
|
+
if (varMatch && pos.character >= line.indexOf(varMatch[0]) && pos.character <= line.indexOf(varMatch[0]) + varMatch[0].length) {
|
|
611
|
+
const startIdx = line.indexOf(varMatch[0]);
|
|
612
|
+
const endIdx = startIdx + varMatch[0].length;
|
|
613
|
+
ranges.push(new vscode.SelectionRange(new vscode.Range(pos.line, startIdx, pos.line, endIdx)));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
ranges.push(new vscode.SelectionRange(new vscode.Range(pos, pos)));
|
|
617
|
+
results.push(ranges);
|
|
618
|
+
}
|
|
619
|
+
return results;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
// References Provider
|
|
625
|
+
const referencesProvider = vscode.languages.registerReferencesProvider(
|
|
626
|
+
['miki-template', 'django-html'],
|
|
627
|
+
{
|
|
628
|
+
provideReferences(document, position, context) {
|
|
629
|
+
const line = document.lineAt(position).text;
|
|
630
|
+
const results = [];
|
|
631
|
+
|
|
632
|
+
const blockDefMatch = line.match(/\{%-?\s*block\s+(\w+)/);
|
|
633
|
+
if (blockDefMatch) {
|
|
634
|
+
const blockName = blockDefMatch[1];
|
|
635
|
+
results.push(...getAllReferences(`block.super`, workspaceFolder));
|
|
636
|
+
results.push(...getAllReferences(`{{ block.${blockName} }}`, workspaceFolder));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const includeMatch = line.match(/\{%-?\s*include\s+["']([^"']+)["']/);
|
|
640
|
+
if (includeMatch) {
|
|
641
|
+
const fileName = includeMatch[1];
|
|
642
|
+
results.push(...getAllReferences(`include "${fileName}"`, workspaceFolder));
|
|
643
|
+
results.push(...getAllReferences(`include '${fileName}'`, workspaceFolder));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return results;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
// Code Actions Provider
|
|
652
|
+
const codeActionsProvider = vscode.languages.registerCodeActionsProvider(
|
|
653
|
+
['miki-template', 'django-html'],
|
|
654
|
+
{
|
|
655
|
+
provideCodeActions(document, range, context) {
|
|
656
|
+
if (!config.get('enableCodeActions', true)) return [];
|
|
657
|
+
|
|
658
|
+
const actions = [];
|
|
659
|
+
const line = document.lineAt(range.start.line).text;
|
|
660
|
+
|
|
661
|
+
const openIfs = (line.match(/\{%-?\s*if\b/g) || []).length;
|
|
662
|
+
const closeIfs = (line.match(/\{%-?\s*endif\b/g) || []).length;
|
|
663
|
+
if (openIfs > closeIfs) {
|
|
664
|
+
actions.push(new vscode.CodeAction('Add missing {% endif %}', { command: 'type', arguments: ['{% endif %}'] }, vscode.CodeActionKind.QuickFix));
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const openFors = (line.match(/\{%-?\s*for\b/g) || []).length;
|
|
668
|
+
const closeFors = (line.match(/\{%-?\s*endfor\b/g) || []).length;
|
|
669
|
+
if (openFors > closeFors) {
|
|
670
|
+
actions.push(new vscode.CodeAction('Add missing {% endfor %}', { command: 'type', arguments: ['{% endfor %}'] }, vscode.CodeActionKind.QuickFix));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (line.includes('{{') && !line.includes('{% block')) {
|
|
674
|
+
const action = new vscode.CodeAction('Wrap in {% block %}');
|
|
675
|
+
action.command = { command: 'miki-template.wrapInBlock', title: 'Wrap in Block' };
|
|
676
|
+
actions.push(action);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
return actions;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
// Color Decorations
|
|
685
|
+
function updateColorDecorations(document) {
|
|
686
|
+
if (!config.get('enableColorDecorations', true)) {
|
|
687
|
+
if (colorDecorationType) {
|
|
688
|
+
colorDecorationType.dispose();
|
|
689
|
+
colorDecorationType = null;
|
|
690
|
+
}
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (!colorDecorationType) {
|
|
695
|
+
colorDecorationType = vscode.window.createTextEditorDecorationType({
|
|
696
|
+
backgroundColor: new vscode.ThemeColor('editor.wordHighlightBackground'),
|
|
697
|
+
borderRadius: '2px'
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const editors = vscode.window.visibleTextEditors.filter(e => e.document === document);
|
|
702
|
+
const decorations = [];
|
|
703
|
+
|
|
704
|
+
for (let i = 0; i < document.lineCount; i++) {
|
|
705
|
+
const line = document.lineAt(i);
|
|
706
|
+
let match;
|
|
707
|
+
const regex = new RegExp(COLOR_REGEX);
|
|
708
|
+
while ((match = regex.exec(line.text)) !== null) {
|
|
709
|
+
decorations.push({
|
|
710
|
+
range: new vscode.Range(i, match.index, i, match.index + match[0].length)
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
editors.forEach(editor => editor.setDecorations(colorDecorationType, decorations));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Bracket Matching Highlights
|
|
719
|
+
function updateBracketHighlights(document) {
|
|
720
|
+
if (!config.get('enableBracketHighlight', true)) {
|
|
721
|
+
bracketHighlightDecorations.forEach(d => d.dispose());
|
|
722
|
+
bracketHighlightDecorations.clear();
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const decorations = [];
|
|
727
|
+
const text = document.getText();
|
|
728
|
+
|
|
729
|
+
for (let i = 0; i < text.length; i++) {
|
|
730
|
+
if (text.substring(i, i + 2) === '{%') {
|
|
731
|
+
const endIdx = text.indexOf('%}', i);
|
|
732
|
+
if (endIdx !== -1) {
|
|
733
|
+
const tagContent = text.substring(i + 2, endIdx).trim();
|
|
734
|
+
const openMatch = tagContent.match(/^(if|for|with|block|comment|verbatim|spaceless|autoescape|filter|cache|addtoblock|partialdef|blocktrans|language)\b/);
|
|
735
|
+
const closeMatch = tagContent.match(/^end(if|for|with|block|comment|verbatim|spaceless|autoescape|filter|cache|addtoblock|partialdef|blocktrans|language)\b/);
|
|
736
|
+
|
|
737
|
+
if (openMatch) {
|
|
738
|
+
decorations.push({
|
|
739
|
+
range: new vscode.Range(document.positionAt(i), document.positionAt(i + 2)),
|
|
740
|
+
options: { color: 'editorBracketHighlight.foreground1' }
|
|
741
|
+
});
|
|
742
|
+
} else if (closeMatch) {
|
|
743
|
+
decorations.push({
|
|
744
|
+
range: new vscode.Range(document.positionAt(i), document.positionAt(i + 2)),
|
|
745
|
+
options: { color: 'editorBracketHighlight.foreground2' }
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
i = endIdx + 1;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const editor = vscode.window.activeTextEditor;
|
|
754
|
+
if (editor && editor.document === document) {
|
|
755
|
+
const decoType = vscode.window.createTextEditorDecorationType({});
|
|
756
|
+
editor.setDecorations(decoType, decorations);
|
|
757
|
+
bracketHighlightDecorations.set(document.uri.toString(), decoType);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Validation
|
|
762
|
+
function validateDocument(document) {
|
|
763
|
+
if (!config.get('enableValidation', true)) {
|
|
764
|
+
diagnosticCollection.delete(document.uri);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const diagnostics = [];
|
|
769
|
+
const lines = document.getText().split('\n');
|
|
770
|
+
const openTags = [];
|
|
771
|
+
|
|
772
|
+
lines.forEach((line, index) => {
|
|
773
|
+
if (line.match(/\{%-?\s*extends\s+["']([^"']+)["']/) && index > 0) {
|
|
774
|
+
const prevContent = lines.slice(0, index).join('').replace(/\s/g, '');
|
|
775
|
+
if (prevContent.match(/\{%/)) {
|
|
776
|
+
diagnostics.push(new vscode.Diagnostic(
|
|
777
|
+
new vscode.Range(index, 0, index, line.length),
|
|
778
|
+
'{% extends %} must be the first tag',
|
|
779
|
+
vscode.DiagnosticSeverity.Warning
|
|
780
|
+
));
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const openTagMatch = line.match(/\{%-?\s*(if|elif|for|with|block|comment|verbatim|spaceless|autoescape|filter|cache|addtoblock|partialdef|blocktrans|language)\b[^}]*%}/g);
|
|
785
|
+
if (openTagMatch) {
|
|
786
|
+
openTagMatch.forEach(tag => {
|
|
787
|
+
const nameMatch = tag.match(/\{%-?\s*(\w+)/);
|
|
788
|
+
if (nameMatch && !['elif', 'empty', 'else', 'plural'].includes(nameMatch[1])) {
|
|
789
|
+
openTags.push({ name: nameMatch[1], line: index + 1 });
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const closeTagMatch = line.match(/\{%-?\s*end(if|elif|for|with|block|comment|verbatim|spaceless|autoescape|filter|cache|addtoblock|partialdef|blocktrans|language)\b[^}]*%}/g);
|
|
795
|
+
if (closeTagMatch) {
|
|
796
|
+
closeTagMatch.forEach(tag => {
|
|
797
|
+
const nameMatch = tag.match(/\{%-?\s*end(\w+)/);
|
|
798
|
+
if (nameMatch) {
|
|
799
|
+
const idx = openTags.findIndex(t => t.name === nameMatch[1]);
|
|
800
|
+
if (idx !== -1) openTags.splice(idx, 1);
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
for (const tag of openTags) {
|
|
807
|
+
if (!['empty', 'else', 'elif', 'plural'].includes(tag.name)) {
|
|
808
|
+
diagnostics.push(new vscode.Diagnostic(
|
|
809
|
+
new vscode.Range(tag.line - 1, 0, tag.line - 1, 100),
|
|
810
|
+
`Unclosed tag: {% ${tag.name} %}`,
|
|
811
|
+
vscode.DiagnosticSeverity.Warning
|
|
812
|
+
));
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
diagnosticCollection.set(document.uri, diagnostics);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const debouncedValidate = debounce(validateDocument, 300);
|
|
820
|
+
const debouncedColorUpdate = debounce(updateColorDecorations, 200);
|
|
821
|
+
const debouncedBracketUpdate = debounce(updateBracketHighlights, 200);
|
|
822
|
+
|
|
823
|
+
context.subscriptions.push(
|
|
824
|
+
vscode.workspace.onDidChangeTextDocument(event => {
|
|
825
|
+
if (event.document.languageId === 'miki-template' || event.document.languageId === 'django-html') {
|
|
826
|
+
debouncedValidate(event.document);
|
|
827
|
+
debouncedColorUpdate(event.document);
|
|
828
|
+
debouncedBracketUpdate(event.document);
|
|
829
|
+
}
|
|
830
|
+
}),
|
|
831
|
+
vscode.workspace.onDidOpenTextDocument(document => {
|
|
832
|
+
if (document.languageId === 'miki-template' || document.languageId === 'django-html') {
|
|
833
|
+
validateDocument(document);
|
|
834
|
+
updateColorDecorations(document);
|
|
835
|
+
updateBracketHighlights(document);
|
|
836
|
+
}
|
|
837
|
+
}),
|
|
838
|
+
vscode.window.onDidChangeVisibleTextEditors(editors => {
|
|
839
|
+
editors.forEach(editor => {
|
|
840
|
+
if (editor.document.languageId === 'miki-template' || editor.document.languageId === 'django-html') {
|
|
841
|
+
updateColorDecorations(editor.document);
|
|
842
|
+
updateBracketHighlights(editor.document);
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
})
|
|
846
|
+
);
|
|
847
|
+
|
|
848
|
+
// Smart Paste
|
|
849
|
+
context.subscriptions.push(
|
|
850
|
+
vscode.workspace.onWillPaste(async e => {
|
|
851
|
+
const editor = vscode.window.activeTextEditor;
|
|
852
|
+
if (!editor) return;
|
|
853
|
+
if (editor.document.languageId !== 'miki-template' && editor.document.languageId !== 'django-html') return;
|
|
854
|
+
if (!config.get('enableSmartPaste', true)) return;
|
|
855
|
+
|
|
856
|
+
const pasteText = e.text;
|
|
857
|
+
const hasHtml = /<[a-z][\s\S]*>/i.test(pasteText);
|
|
858
|
+
|
|
859
|
+
if (hasHtml && !pasteText.includes('|safe') && !pasteText.includes('|escape')) {
|
|
860
|
+
e.text = pasteText + '|safe';
|
|
861
|
+
}
|
|
862
|
+
})
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
// Commands
|
|
866
|
+
context.subscriptions.push(
|
|
867
|
+
vscode.commands.registerCommand('miki-template.validateAll', () => {
|
|
868
|
+
vscode.workspace.textDocuments.forEach(doc => {
|
|
869
|
+
if (doc.languageId === 'miki-template' || doc.languageId === 'django-html') {
|
|
870
|
+
validateDocument(doc);
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
vscode.window.showInformationMessage('Template validation complete');
|
|
874
|
+
}),
|
|
875
|
+
|
|
876
|
+
vscode.commands.registerCommand('miki-template.insertFilter', () => {
|
|
877
|
+
const editor = vscode.window.activeTextEditor;
|
|
878
|
+
if (!editor) return;
|
|
879
|
+
const selection = editor.selection;
|
|
880
|
+
const selectedText = editor.document.getText(selection);
|
|
881
|
+
if (selectedText) {
|
|
882
|
+
editor.edit(editBuilder => {
|
|
883
|
+
editBuilder.replace(selection, `{{ ${selectedText}| }}`);
|
|
884
|
+
}).then(() => {
|
|
885
|
+
const newPos = selection.start.translate(0, selectedText.length + 4);
|
|
886
|
+
editor.selection = new vscode.Selection(newPos, newPos);
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
}),
|
|
890
|
+
|
|
891
|
+
vscode.commands.registerCommand('miki-template.wrapInBlock', () => {
|
|
892
|
+
const editor = vscode.window.activeTextEditor;
|
|
893
|
+
if (!editor) return;
|
|
894
|
+
const selection = editor.selection;
|
|
895
|
+
const selectedText = editor.document.getText(selection);
|
|
896
|
+
editor.edit(editBuilder => {
|
|
897
|
+
editBuilder.replace(selection, `{% block ${1:name} %}\n${selectedText}\n{% endblock %}`);
|
|
898
|
+
});
|
|
899
|
+
}),
|
|
900
|
+
|
|
901
|
+
vscode.commands.registerCommand('miki-template.wrapInFor', () => {
|
|
902
|
+
const editor = vscode.window.activeTextEditor;
|
|
903
|
+
if (!editor) return;
|
|
904
|
+
const selection = editor.selection;
|
|
905
|
+
const selectedText = editor.document.getText(selection);
|
|
906
|
+
editor.edit(editBuilder => {
|
|
907
|
+
editBuilder.replace(selection, `{% for ${1:item} in ${2:items} %}\n${selectedText}\n{% endfor %}`);
|
|
908
|
+
});
|
|
909
|
+
}),
|
|
910
|
+
|
|
911
|
+
vscode.commands.registerCommand('miki-template.wrapInIf', () => {
|
|
912
|
+
const editor = vscode.window.activeTextEditor;
|
|
913
|
+
if (!editor) return;
|
|
914
|
+
const selection = editor.selection;
|
|
915
|
+
const selectedText = editor.document.getText(selection);
|
|
916
|
+
editor.edit(editBuilder => {
|
|
917
|
+
editBuilder.replace(selection, `{% if ${1:condition} %}\n${selectedText}\n{% endif %}`);
|
|
918
|
+
});
|
|
919
|
+
}),
|
|
920
|
+
|
|
921
|
+
vscode.commands.registerCommand('miki-template.addPrettierIgnore', () => {
|
|
922
|
+
const editor = vscode.window.activeTextEditor;
|
|
923
|
+
if (!editor) return;
|
|
924
|
+
const line = editor.selection.start.line;
|
|
925
|
+
editor.edit(editBuilder => {
|
|
926
|
+
editBuilder.insert(new vscode.Position(line, 0), '{# prettier-ignore #}\n');
|
|
927
|
+
});
|
|
928
|
+
}),
|
|
929
|
+
|
|
930
|
+
vscode.commands.registerCommand('miki-template.goToNextBlock', () => {
|
|
931
|
+
const editor = vscode.window.activeTextEditor;
|
|
932
|
+
if (!editor) return;
|
|
933
|
+
const doc = editor.document;
|
|
934
|
+
const pos = editor.selection.active;
|
|
935
|
+
for (let i = pos.line + 1; i < doc.lineCount; i++) {
|
|
936
|
+
if (doc.lineAt(i).text.includes('{% block ')) {
|
|
937
|
+
editor.selection = new vscode.Selection(i, 0, i, 0);
|
|
938
|
+
editor.revealRange(new vscode.Range(i, 0, i, 0));
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}),
|
|
943
|
+
|
|
944
|
+
vscode.commands.registerCommand('miki-template.goToPrevBlock', () => {
|
|
945
|
+
const editor = vscode.window.activeTextEditor;
|
|
946
|
+
if (!editor) return;
|
|
947
|
+
const doc = editor.document;
|
|
948
|
+
const pos = editor.selection.active;
|
|
949
|
+
for (let i = pos.line - 1; i >= 0; i--) {
|
|
950
|
+
if (doc.lineAt(i).text.includes('{% block ')) {
|
|
951
|
+
editor.selection = new vscode.Selection(i, 0, i, 0);
|
|
952
|
+
editor.revealRange(new vscode.Range(i, 0, i, 0));
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}),
|
|
957
|
+
|
|
958
|
+
vscode.commands.registerCommand('miki-template.previewTemplate', async () => {
|
|
959
|
+
const editor = vscode.window.activeTextEditor;
|
|
960
|
+
if (!editor) return;
|
|
961
|
+
const content = editor.document.getText();
|
|
962
|
+
const panel = vscode.window.createWebviewPanel('templatePreview', 'Template Preview', vscode.ViewColumn.Two);
|
|
963
|
+
const htmlContent = `<!DOCTYPE html><html><head><meta charset="UTF-8"><style>body{font-family:system-ui;padding:20px;background:#1e1e1e;color:#d4d4d4}</style></head><body><pre>${content.replace(/</g, '<').replace(/>/g, '>')}</pre></body></html>`;
|
|
964
|
+
panel.webview.html = htmlContent;
|
|
965
|
+
}),
|
|
966
|
+
|
|
967
|
+
vscode.commands.registerCommand('miki-template.showOutline', async () => {
|
|
968
|
+
const symbols = await vscode.commands.executeCommand('vscode.executeDocumentSymbolProvider', vscode.window.activeTextEditor.document.uri);
|
|
969
|
+
if (symbols && symbols.length > 0) {
|
|
970
|
+
const items = symbols.map(s => ({ label: s.name, detail: vscode.SymbolKind[s.kind] }));
|
|
971
|
+
const selected = await vscode.window.showQuickPick(items);
|
|
972
|
+
if (selected) {
|
|
973
|
+
const symbol = symbols.find(s => s.name === selected.label);
|
|
974
|
+
if (symbol) {
|
|
975
|
+
const range = symbol.location.range;
|
|
976
|
+
vscode.window.activeTextEditor.selection = new vscode.Selection(range.start, range.start);
|
|
977
|
+
vscode.window.activeTextEditor.revealRange(range);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}),
|
|
982
|
+
|
|
983
|
+
vscode.commands.registerCommand('miki-template.findBlockReferences', async () => {
|
|
984
|
+
const editor = vscode.window.activeTextEditor;
|
|
985
|
+
if (!editor) return;
|
|
986
|
+
const refs = await vscode.commands.executeCommand('vscode.executeReferenceProvider', editor.document.uri, editor.selection.active);
|
|
987
|
+
if (refs && refs.length > 0) {
|
|
988
|
+
await vscode.commands.executeCommand('editor.action.showReferences', editor.document.uri, editor.selection.active, refs);
|
|
989
|
+
}
|
|
990
|
+
})
|
|
991
|
+
);
|
|
992
|
+
|
|
993
|
+
context.subscriptions.push(
|
|
994
|
+
completionProvider,
|
|
995
|
+
hoverProvider,
|
|
996
|
+
semanticTokensProvider,
|
|
997
|
+
inlayHintsProvider,
|
|
998
|
+
definitionProvider,
|
|
999
|
+
renameProvider,
|
|
1000
|
+
symbolProvider,
|
|
1001
|
+
selectionRangeProvider,
|
|
1002
|
+
referencesProvider,
|
|
1003
|
+
codeActionsProvider
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function deactivate() {
|
|
1008
|
+
if (diagnosticCollection) diagnosticCollection.clear();
|
|
1009
|
+
if (colorDecorationType) colorDecorationType.dispose();
|
|
1010
|
+
bracketHighlightDecorations.forEach(d => d.dispose());
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
module.exports = { activate, deactivate };
|