miki-template 1.2.0 → 1.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.github/release-notes/v1.3.1.md +55 -0
  2. package/.github/release-notes/v1.3.3.md +77 -0
  3. package/.github/workflows/ci.yml +38 -54
  4. package/.github/workflows/release.yml +106 -0
  5. package/AGENT.md +71 -71
  6. package/API_REFERENCE.md +314 -314
  7. package/CHANGELOG.md +173 -97
  8. package/CODE_OF_CONDUCT.md +14 -14
  9. package/CONTRIBUTING.md +27 -27
  10. package/README.md +342 -304
  11. package/ROADMAP.md +40 -40
  12. package/assets/banner.png +0 -0
  13. package/benchmarks/report.json +16 -16
  14. package/benchmarks/run.js +49 -49
  15. package/benchmarks/stress.mjs +647 -0
  16. package/benchmarks/templates/large.dtpl +7 -7
  17. package/benchmarks/templates/medium.dtpl +3 -3
  18. package/benchmarks/templates/small.dtpl +7 -7
  19. package/context/component.md +109 -109
  20. package/context/prd.md +131 -131
  21. package/context/project-structure.md +33 -33
  22. package/dir/base.html +23 -0
  23. package/dir/cmpnt.html +11 -0
  24. package/dir/footer.html +3 -0
  25. package/dir/home.html +80 -0
  26. package/dir/index.html +80 -0
  27. package/dir/navbar.html +9 -0
  28. package/docs/README.md +18 -18
  29. package/docs/advanced_usage.md +71 -71
  30. package/docs/api.md +119 -102
  31. package/docs/filters.md +708 -540
  32. package/docs/installation.md +106 -106
  33. package/docs/overview.md +57 -57
  34. package/docs/partialdef.md +70 -41
  35. package/docs/security.md +27 -27
  36. package/docs/tags.md +673 -610
  37. package/docs/usage.md +646 -599
  38. package/eslint.config.mjs +42 -34
  39. package/ex.mjs +33 -0
  40. package/miki-template-extension/.github/workflows/ci.yml +116 -0
  41. package/miki-template-extension/.vscodeignore +7 -0
  42. package/miki-template-extension/CHANGELOG.md +99 -0
  43. package/miki-template-extension/LICENSE +21 -21
  44. package/miki-template-extension/README.md +273 -82
  45. package/miki-template-extension/extension.js +1013 -0
  46. package/miki-template-extension/icon.png +0 -0
  47. package/miki-template-extension/icon.svg +10 -10
  48. package/miki-template-extension/miki-template-1.7.1.vsix +0 -0
  49. package/miki-template-extension/package.json +280 -46
  50. package/miki-template-extension/snippets/miki-template.json +717 -177
  51. package/miki-template-extension/syntaxes/language-configuration.json +114 -26
  52. package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +355 -146
  53. package/miki-template-extension/tests/grammar-tests.json +162 -0
  54. package/miki-template-extension/tests/run-grammar-tests.js +82 -0
  55. package/package.json +37 -31
  56. package/sample-app/package-lock.json +901 -0
  57. package/sample-app/package.json +9 -0
  58. package/sample-app/server.js +14 -0
  59. package/sample-app/views/index.html +1 -0
  60. package/scripts/build-vsix.js +129 -0
  61. package/scripts/build-vsix.ps1 +15 -0
  62. package/snippets/miki-template.json +177 -177
  63. package/src/asyncRender.js +20 -20
  64. package/src/cache.js +80 -41
  65. package/src/context.js +126 -122
  66. package/src/context_processors.js +48 -41
  67. package/src/esm.mjs +84 -72
  68. package/src/filters.js +975 -527
  69. package/src/i18n.js +171 -171
  70. package/src/index.js +974 -454
  71. package/src/lexer.js +114 -92
  72. package/src/libraries.js +371 -240
  73. package/src/parser.js +270 -250
  74. package/src/security.js +53 -51
  75. package/src/tags/control.js +719 -590
  76. package/src/tags/extra.js +154 -0
  77. package/src/tags/helpers.js +26 -26
  78. package/src/tags/i18n.js +256 -230
  79. package/src/tags/inheritance.js +335 -216
  80. package/src/tags/registry.js +18 -18
  81. package/src/tags/util.js +400 -322
  82. package/src/types.d.ts +107 -107
  83. package/syntaxes/language-configuration.json +26 -26
  84. package/syntaxes/miki-template.tmLanguage.json +146 -146
  85. package/tests/asyncRender.test.js +17 -17
  86. package/tests/base.html +6 -6
  87. package/tests/child.html +3 -3
  88. package/tests/context_processors.test.js +13 -13
  89. package/tests/esm.test.mjs +61 -26
  90. package/tests/filters.test.js +254 -99
  91. package/tests/include_security.test.js +9 -9
  92. package/tests/integration/README.md +32 -0
  93. package/tests/integration/features.test.cjs +1681 -0
  94. package/tests/integration/features.test.mjs +1697 -0
  95. package/tests/integration/templates/base.miki +6 -0
  96. package/tests/integration/templates/child.miki +6 -0
  97. package/tests/integration/templates/index.html +17 -0
  98. package/tests/lexer.test.js +45 -45
  99. package/tests/parser.test.js +57 -55
  100. package/tests/partial.html +1 -1
  101. package/tests/partialdef.test.js +79 -40
  102. package/tests/production_checks.js +57 -57
  103. package/tests/security.test.js +28 -28
  104. package/tests/tags.test.js +233 -203
  105. package/miki-template-1.2.0.vsix +0 -0
package/src/parser.js CHANGED
@@ -1,250 +1,270 @@
1
- /**
2
- * Parser converting token stream into AST Nodes.
3
- */
4
- const { escapeHtml, isSafe } = require('./security');
5
- const filtersModule = require('./filters');
6
-
7
- class TextNode {
8
- constructor(content) {
9
- this.content = content;
10
- }
11
-
12
- render(_context) {
13
- return this.content;
14
- }
15
- }
16
-
17
- class VariableNode {
18
- constructor(varPath, filters, isLiteral = false, literalValue = null) {
19
- this.varPath = varPath;
20
- this.filters = filters;
21
- this.isLiteral = isLiteral;
22
- this.literalValue = literalValue;
23
- }
24
-
25
- render(context) {
26
- let val = this.isLiteral ? this.literalValue : context.get(this.varPath);
27
-
28
- for (const filterInfo of this.filters) {
29
- const filterFn = filtersModule.getFilter(filterInfo.name);
30
- if (!filterFn) {
31
- throw new Error(`Unknown filter: '${filterInfo.name}'`);
32
- }
33
-
34
- let argVal = undefined;
35
- if (filterInfo.arg) {
36
- if (filterInfo.arg.type === 'literal') {
37
- argVal = filterInfo.arg.value;
38
- } else if (filterInfo.arg.type === 'variable') {
39
- argVal = context.get(filterInfo.arg.value);
40
- }
41
- }
42
-
43
- val = filterFn(val, argVal);
44
- }
45
-
46
- // Auto-escape logic – skip escaping if value is marked safe
47
- if (context.autoescape && !isSafe(val)) {
48
- return escapeHtml(val);
49
- }
50
-
51
- return val === null || val === undefined ? '' : String(val);
52
- }
53
- }
54
-
55
- /**
56
- * Parses a variable expression including filters and arguments.
57
- * E.g., user.name|lower|default:"Guest"
58
- */
59
- function parseVariableExpression(expr) {
60
- let idx = 0;
61
- const len = expr.length;
62
-
63
- function skipWhitespace() {
64
- while (idx < len && /\s/.test(expr[idx])) {
65
- idx++;
66
- }
67
- }
68
-
69
- let varPath = '';
70
- let isLiteral = false;
71
- let literalValue = null;
72
-
73
- // Check for string literal as base value
74
- if (idx < len && (expr[idx] === '"' || expr[idx] === '\'')) {
75
- const quote = expr[idx];
76
- idx++;
77
- let strVal = '';
78
- while (idx < len && expr[idx] !== quote) {
79
- if (expr[idx] === '\\' && idx + 1 < len) {
80
- idx++;
81
- }
82
- strVal += expr[idx];
83
- idx++;
84
- }
85
- idx++; // Skip closing quote
86
- isLiteral = true;
87
- literalValue = strVal;
88
- skipWhitespace();
89
- }
90
-
91
- // Parse main variable path
92
- if (!isLiteral) {
93
- while (idx < len && expr[idx] !== '|') {
94
- varPath += expr[idx];
95
- idx++;
96
- }
97
- varPath = varPath.trim();
98
- }
99
-
100
- const filters = [];
101
- while (idx < len) {
102
- if (expr[idx] === '|') {
103
- idx++; // Skip '|'
104
- skipWhitespace();
105
-
106
- // Read filter name
107
- let filterName = '';
108
- while (idx < len && expr[idx] !== ':' && expr[idx] !== '|' && !/\s/.test(expr[idx])) {
109
- filterName += expr[idx];
110
- idx++;
111
- }
112
-
113
- skipWhitespace();
114
- let arg = null;
115
-
116
- if (idx < len && expr[idx] === ':') {
117
- idx++; // Skip ':'
118
- skipWhitespace();
119
-
120
- // Parse argument (string literal, number, boolean, null, or variable)
121
- if (expr[idx] === '"' || expr[idx] === '\'') {
122
- const quote = expr[idx];
123
- idx++; // Skip opening quote
124
- let argVal = '';
125
- while (idx < len && expr[idx] !== quote) {
126
- if (expr[idx] === '\\') {
127
- idx++;
128
- }
129
- if (idx < len) {
130
- argVal += expr[idx];
131
- idx++;
132
- }
133
- }
134
- idx++; // Skip closing quote
135
- arg = { type: 'literal', value: argVal };
136
- } else {
137
- // Read unquoted token
138
- let argVal = '';
139
- while (idx < len && expr[idx] !== '|' && !/\s/.test(expr[idx])) {
140
- argVal += expr[idx];
141
- idx++;
142
- }
143
- argVal = argVal.trim();
144
-
145
- // Type coercion
146
- if (!isNaN(argVal) && argVal !== '') {
147
- arg = { type: 'literal', value: Number(argVal) };
148
- } else if (argVal === 'true') {
149
- arg = { type: 'literal', value: true };
150
- } else if (argVal === 'false') {
151
- arg = { type: 'literal', value: false };
152
- } else if (argVal === 'none' || argVal === 'None' || argVal === 'null') {
153
- arg = { type: 'literal', value: null };
154
- } else {
155
- // Variable lookup
156
- arg = { type: 'variable', value: argVal };
157
- }
158
- }
159
- }
160
-
161
- filters.push({ name: filterName, arg });
162
- skipWhitespace();
163
- } else {
164
- idx++;
165
- }
166
- }
167
-
168
- return { varPath, filters, isLiteral, literalValue };
169
- }
170
-
171
- class Parser {
172
- // Existing constructor and methods remain unchanged
173
- /**
174
- * Parse tokens until a specific end tag, consuming the end tag token.
175
- * @param {string} endTag - Tag name to stop at (e.g., 'endsleep').
176
- * @returns {Array} Parsed nodes.
177
- */
178
- parseUntilTag(endTag) {
179
- const nodes = this.parse([endTag]);
180
- // Advance past the end tag token if present
181
- const next = this.peek();
182
- if (next && next.type === 'block' && next.content.split(/\s+/)[0] === endTag) {
183
- this.advance();
184
- }
185
- return nodes;
186
- }
187
-
188
- constructor(tokens, tagRegistry = {}) {
189
- this.tokens = tokens;
190
- this.index = 0;
191
- this.tagRegistry = tagRegistry;
192
- }
193
-
194
- /**
195
- * Parses tokens into AST nodes until one of the tags in untilTags is encountered.
196
- */
197
- parse(untilTags = []) {
198
- const nodes = [];
199
- while (this.index < this.tokens.length) {
200
- const token = this.tokens[this.index];
201
-
202
- if (token.type === 'text') {
203
- nodes.push(new TextNode(token.content));
204
- this.index++;
205
- } else if (token.type === 'var') {
206
- const parsed = parseVariableExpression(token.content);
207
- nodes.push(new VariableNode(parsed.varPath, parsed.filters, parsed.isLiteral, parsed.literalValue));
208
- this.index++;
209
- } else if (token.type === 'block') {
210
- const parts = token.content.split(/\s+/);
211
- const tagName = parts[0];
212
-
213
- // Stop parsing if we hit a stop tag
214
- if (untilTags.includes(tagName)) {
215
- break;
216
- }
217
-
218
- this.index++; // Consume block token
219
-
220
- const tagParser = this.tagRegistry[tagName];
221
- if (!tagParser) {
222
- throw new Error(`Unknown template tag: '${tagName}'`);
223
- }
224
-
225
- const node = tagParser(token.content, this);
226
- nodes.push(node);
227
- }
228
- }
229
- // If we were looking for specific end tags but didn't find them, throw
230
- if (untilTags.length > 0 && this.index >= this.tokens.length) {
231
- throw new Error(`Unexpected end of template - expected one of: ${untilTags.map(t => '{% ' + t + ' %}').join(', ')}`);
232
- }
233
- return nodes;
234
- }
235
-
236
- peek() {
237
- return this.tokens[this.index];
238
- }
239
-
240
- advance() {
241
- this.index++;
242
- }
243
- }
244
-
245
- module.exports = {
246
- Parser,
247
- TextNode,
248
- VariableNode,
249
- parseVariableExpression
250
- };
1
+ /**
2
+ * Parser converting token stream into AST Nodes.
3
+ */
4
+ const { escapeHtml, isSafe } = require('./security');
5
+ const filtersModule = require('./filters');
6
+
7
+ class TextNode {
8
+ constructor(content) {
9
+ this.content = content;
10
+ }
11
+
12
+ render(_context) {
13
+ return this.content;
14
+ }
15
+ }
16
+
17
+ class VariableNode {
18
+ constructor(varPath, filters, isLiteral = false, literalValue = null) {
19
+ this.varPath = varPath;
20
+ this.filters = filters;
21
+ this.isLiteral = isLiteral;
22
+ this.literalValue = literalValue;
23
+ }
24
+
25
+ render(context) {
26
+ let val = this.isLiteral ? this.literalValue : context.get(this.varPath);
27
+
28
+ for (const filterInfo of this.filters) {
29
+ const filterFn = filtersModule.getFilter(filterInfo.name);
30
+ if (!filterFn) {
31
+ throw new Error(`Unknown filter: '${filterInfo.name}'`);
32
+ }
33
+
34
+ let argVal = undefined;
35
+ if (filterInfo.arg) {
36
+ if (filterInfo.arg.type === 'literal') {
37
+ argVal = filterInfo.arg.value;
38
+ } else if (filterInfo.arg.type === 'variable') {
39
+ argVal = context.get(filterInfo.arg.value);
40
+ }
41
+ }
42
+
43
+ val = filterFn(val, argVal, context);
44
+ }
45
+
46
+ // Auto-escape logic – skip escaping if value is marked safe
47
+ if (context.autoescape && !isSafe(val)) {
48
+ return escapeHtml(val);
49
+ }
50
+
51
+ return val === null || val === undefined ? '' : String(val);
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Parses a variable expression including filters and arguments.
57
+ * E.g., user.name|lower|default:"Guest"
58
+ */
59
+ function parseVariableExpression(expr) {
60
+ let idx = 0;
61
+ const len = expr.length;
62
+
63
+ function skipWhitespace() {
64
+ while (idx < len && /\s/.test(expr[idx])) {
65
+ idx++;
66
+ }
67
+ }
68
+
69
+ let varPath = '';
70
+ let isLiteral = false;
71
+ let literalValue = null;
72
+
73
+ // Check for string literal as base value
74
+ if (idx < len && (expr[idx] === '"' || expr[idx] === '\'')) {
75
+ const quote = expr[idx];
76
+ idx++;
77
+ let strVal = '';
78
+ while (idx < len && expr[idx] !== quote) {
79
+ if (expr[idx] === '\\' && idx + 1 < len) {
80
+ idx++;
81
+ }
82
+ strVal += expr[idx];
83
+ idx++;
84
+ }
85
+ idx++; // Skip closing quote
86
+ isLiteral = true;
87
+ literalValue = strVal;
88
+ skipWhitespace();
89
+ }
90
+
91
+ // Parse main variable path
92
+ if (!isLiteral) {
93
+ while (idx < len && expr[idx] !== '|') {
94
+ varPath += expr[idx];
95
+ idx++;
96
+ }
97
+ varPath = varPath.trim();
98
+ }
99
+
100
+ const filters = [];
101
+ while (idx < len) {
102
+ if (expr[idx] === '|') {
103
+ idx++; // Skip '|'
104
+ skipWhitespace();
105
+
106
+ // Read filter name
107
+ let filterName = '';
108
+ while (idx < len && expr[idx] !== ':' && expr[idx] !== '|' && !/\s/.test(expr[idx])) {
109
+ filterName += expr[idx];
110
+ idx++;
111
+ }
112
+
113
+ skipWhitespace();
114
+ let arg = null;
115
+
116
+ if (idx < len && expr[idx] === ':') {
117
+ idx++; // Skip ':'
118
+ skipWhitespace();
119
+
120
+ // Parse argument (string literal, number, boolean, null, or variable)
121
+ if (expr[idx] === '"' || expr[idx] === '\'') {
122
+ const quote = expr[idx];
123
+ idx++; // Skip opening quote
124
+ let argVal = '';
125
+ while (idx < len && expr[idx] !== quote) {
126
+ // Track nested {{ ... }} and {% ... %} so the closing
127
+ // quote isn't confused with one inside a nested template
128
+ // expression. This lets us write
129
+ // {{ x|default:"{{ y|upper }}" }}
130
+ // where the inner `}}` must not close the outer string.
131
+ if (expr[idx] === '{' && idx + 1 < len && (expr[idx + 1] === '{' || expr[idx + 1] === '%')) {
132
+ const open = expr.substr(idx, 2);
133
+ const close = open === '{{' ? '}}' : '%}';
134
+ argVal += open;
135
+ idx += 2;
136
+ while (idx < len && expr.substr(idx, close.length) !== close) {
137
+ argVal += expr[idx];
138
+ idx++;
139
+ }
140
+ if (idx < len) {
141
+ argVal += close;
142
+ idx += close.length;
143
+ }
144
+ continue;
145
+ }
146
+ if (expr[idx] === '\\') {
147
+ idx++;
148
+ }
149
+ if (idx < len) {
150
+ argVal += expr[idx];
151
+ idx++;
152
+ }
153
+ }
154
+ if (idx < len) idx++; // Skip closing quote
155
+ arg = { type: 'literal', value: argVal };
156
+ } else {
157
+ // Read unquoted token
158
+ let argVal = '';
159
+ while (idx < len && expr[idx] !== '|' && !/\s/.test(expr[idx])) {
160
+ argVal += expr[idx];
161
+ idx++;
162
+ }
163
+ argVal = argVal.trim();
164
+
165
+ // Type coercion
166
+ if (!isNaN(argVal) && argVal !== '') {
167
+ arg = { type: 'literal', value: Number(argVal) };
168
+ } else if (argVal === 'true') {
169
+ arg = { type: 'literal', value: true };
170
+ } else if (argVal === 'false') {
171
+ arg = { type: 'literal', value: false };
172
+ } else if (argVal === 'none' || argVal === 'None' || argVal === 'null') {
173
+ arg = { type: 'literal', value: null };
174
+ } else {
175
+ // Variable lookup
176
+ arg = { type: 'variable', value: argVal };
177
+ }
178
+ }
179
+ }
180
+
181
+ filters.push({ name: filterName, arg });
182
+ skipWhitespace();
183
+ } else {
184
+ idx++;
185
+ }
186
+ }
187
+
188
+ return { varPath, filters, isLiteral, literalValue };
189
+ }
190
+
191
+ class Parser {
192
+ // Existing constructor and methods remain unchanged
193
+ /**
194
+ * Parse tokens until a specific end tag, consuming the end tag token.
195
+ * @param {string} endTag - Tag name to stop at (e.g., 'endsleep').
196
+ * @returns {Array} Parsed nodes.
197
+ */
198
+ parseUntilTag(endTag) {
199
+ const nodes = this.parse([endTag]);
200
+ // Advance past the end tag token if present
201
+ const next = this.peek();
202
+ if (next && next.type === 'block' && next.content.split(/\s+/)[0] === endTag) {
203
+ this.advance();
204
+ }
205
+ return nodes;
206
+ }
207
+
208
+ constructor(tokens, tagRegistry = {}) {
209
+ this.tokens = tokens;
210
+ this.index = 0;
211
+ this.tagRegistry = tagRegistry;
212
+ }
213
+
214
+ /**
215
+ * Parses tokens into AST nodes until one of the tags in untilTags is encountered.
216
+ */
217
+ parse(untilTags = []) {
218
+ const nodes = [];
219
+ while (this.index < this.tokens.length) {
220
+ const token = this.tokens[this.index];
221
+
222
+ if (token.type === 'text') {
223
+ nodes.push(new TextNode(token.content));
224
+ this.index++;
225
+ } else if (token.type === 'var') {
226
+ const parsed = parseVariableExpression(token.content);
227
+ nodes.push(new VariableNode(parsed.varPath, parsed.filters, parsed.isLiteral, parsed.literalValue));
228
+ this.index++;
229
+ } else if (token.type === 'block') {
230
+ const parts = token.content.split(/\s+/);
231
+ const tagName = parts[0];
232
+
233
+ // Stop parsing if we hit a stop tag
234
+ if (untilTags.includes(tagName)) {
235
+ break;
236
+ }
237
+
238
+ this.index++; // Consume block token
239
+
240
+ const tagParser = this.tagRegistry[tagName];
241
+ if (!tagParser) {
242
+ throw new Error(`Unknown template tag: '${tagName}'`);
243
+ }
244
+
245
+ const node = tagParser(token.content, this);
246
+ nodes.push(node);
247
+ }
248
+ }
249
+ // If we were looking for specific end tags but didn't find them, throw
250
+ if (untilTags.length > 0 && this.index >= this.tokens.length) {
251
+ throw new Error(`Unexpected end of template - expected one of: ${untilTags.map(t => '{% ' + t + ' %}').join(', ')}`);
252
+ }
253
+ return nodes;
254
+ }
255
+
256
+ peek() {
257
+ return this.tokens[this.index];
258
+ }
259
+
260
+ advance() {
261
+ this.index++;
262
+ }
263
+ }
264
+
265
+ module.exports = {
266
+ Parser,
267
+ TextNode,
268
+ VariableNode,
269
+ parseVariableExpression
270
+ };
package/src/security.js CHANGED
@@ -1,51 +1,53 @@
1
- /**
2
- * Security and HTML auto-escaping mechanisms.
3
- */
4
- const he = require('he');
5
-
6
- class SafeString {
7
- constructor(value) {
8
- this.value = value === null || value === undefined ? '' : String(value);
9
- }
10
-
11
- toString() {
12
- return this.value;
13
- }
14
- }
15
-
16
- /**
17
- * Mark a string as safe so it is not auto-escaped during rendering.
18
- */
19
- function markSafe(value) {
20
- if (value instanceof SafeString) {
21
- return value;
22
- }
23
- return new SafeString(value);
24
- }
25
-
26
- /**
27
- * Check if a value is marked safe.
28
- */
29
- function isSafe(value) {
30
- return value instanceof SafeString;
31
- }
32
-
33
- /**
34
- * Escape a string's HTML special characters unless it is marked safe.
35
- */
36
- function escapeHtml(value) {
37
- if (value instanceof SafeString) {
38
- return value.toString();
39
- }
40
-
41
- const str = value === null || value === undefined ? '' : String(value);
42
- // We use he.escape to convert <, >, &, ", ', etc. to entities
43
- return he.escape(str);
44
- }
45
-
46
- module.exports = {
47
- SafeString,
48
- markSafe,
49
- isSafe,
50
- escapeHtml
51
- };
1
+ /**
2
+ * Security and HTML auto-escaping mechanisms.
3
+ */
4
+ const he = require('he');
5
+
6
+ class SafeString {
7
+ constructor(value) {
8
+ this.value = value === null || value === undefined ? '' : String(value);
9
+ }
10
+
11
+ toString() {
12
+ return this.value;
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Mark a string as safe so it is not auto-escaped during rendering.
18
+ */
19
+ function markSafe(value) {
20
+ if (value instanceof SafeString) {
21
+ return value;
22
+ }
23
+ return new SafeString(value);
24
+ }
25
+
26
+ /**
27
+ * Check if a value is marked safe.
28
+ */
29
+ function isSafe(value) {
30
+ return value instanceof SafeString;
31
+ }
32
+
33
+ /**
34
+ * Escape a string's HTML special characters unless it is marked safe.
35
+ * If `force` is true (used by the |escape filter), even a SafeString is
36
+ * re-escaped. This matches Django's `{{ value|escape }}` semantics.
37
+ */
38
+ function escapeHtml(value, force = false) {
39
+ if (value instanceof SafeString && !force) {
40
+ return value.toString();
41
+ }
42
+
43
+ const str = value === null || value === undefined ? '' : String(value);
44
+ // We use he.escape to convert <, >, &, ", ', etc. to entities
45
+ return he.escape(str);
46
+ }
47
+
48
+ module.exports = {
49
+ SafeString,
50
+ markSafe,
51
+ isSafe,
52
+ escapeHtml
53
+ };