miki-template 1.3.3 → 1.3.7
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/.eslintrc.json +16 -0
- package/.github/release-notes/v1.3.1.md +55 -55
- package/.github/release-notes/v1.3.3.md +77 -0
- package/.github/workflows/ci.yml +38 -54
- package/.github/workflows/release.yml +106 -0
- package/AGENT.md +71 -71
- package/API_REFERENCE.md +314 -314
- package/CHANGELOG.md +173 -169
- package/CODE_OF_CONDUCT.md +14 -14
- package/CONTRIBUTING.md +27 -27
- package/README.md +342 -321
- package/ROADMAP.md +40 -40
- package/benchmarks/report.json +16 -16
- package/benchmarks/run.js +49 -49
- package/benchmarks/stress.mjs +647 -647
- package/benchmarks/templates/large.dtpl +7 -7
- package/benchmarks/templates/medium.dtpl +3 -3
- package/benchmarks/templates/small.dtpl +7 -7
- package/context/component.md +109 -109
- package/context/prd.md +131 -131
- package/context/project-structure.md +33 -33
- package/dir/base.html +22 -22
- package/dir/cmpnt.html +10 -10
- package/dir/footer.html +2 -2
- package/dir/home.html +80 -80
- package/dir/index.html +80 -0
- package/dir/navbar.html +8 -8
- package/docs/README.md +18 -18
- package/docs/advanced_usage.md +71 -71
- package/docs/api.md +119 -119
- package/docs/filters.md +708 -708
- package/docs/installation.md +106 -106
- package/docs/overview.md +79 -57
- package/docs/partialdef.md +70 -70
- package/docs/security.md +27 -27
- package/docs/tags.md +673 -673
- package/docs/usage.md +646 -646
- package/eslint.config.mjs +42 -42
- package/ex.mjs +34 -32
- package/live-test/package-lock.json +915 -0
- package/live-test/package.json +9 -0
- package/live-test/server.js +14 -0
- package/live-test/views/base.html +8 -0
- package/live-test/views/child.html +7 -0
- package/live-test/views/index.html +1 -0
- package/miki-template-extension/.github/workflows/ci.yml +116 -116
- package/miki-template-extension/.vscodeignore +7 -7
- package/miki-template-extension/CHANGELOG.md +99 -99
- package/miki-template-extension/LICENSE +21 -21
- package/miki-template-extension/README.md +273 -273
- package/miki-template-extension/extension.js +1013 -1013
- package/miki-template-extension/icon.svg +10 -10
- package/miki-template-extension/package.json +280 -280
- package/miki-template-extension/snippets/miki-template.json +717 -717
- package/miki-template-extension/syntaxes/language-configuration.json +114 -114
- package/miki-template-extension/syntaxes/miki-template.tmLanguage.json +355 -355
- package/miki-template-extension/tests/grammar-tests.json +162 -162
- package/miki-template-extension/tests/run-grammar-tests.js +82 -82
- package/package.json +40 -34
- package/sample-app/package-lock.json +901 -0
- package/sample-app/package.json +9 -0
- package/sample-app/server.js +14 -0
- package/sample-app/views/index.html +1 -0
- package/scripts/build-vsix.js +129 -129
- package/scripts/build-vsix.ps1 +15 -15
- package/snippets/miki-template.json +177 -177
- package/src/asyncRender.js +20 -20
- package/src/cache.js +80 -80
- package/src/context.js +126 -126
- package/src/context_processors.js +48 -48
- package/src/esm.mjs +89 -84
- package/src/filters.js +975 -975
- package/src/i18n.js +171 -171
- package/src/index.js +1112 -940
- package/src/lexer.js +114 -114
- package/src/libraries.js +371 -371
- package/src/parser.js +270 -270
- package/src/security.js +53 -53
- package/src/tags/control.js +719 -719
- package/src/tags/extra.js +154 -154
- package/src/tags/helpers.js +26 -26
- package/src/tags/i18n.js +256 -256
- package/src/tags/inheritance.js +335 -335
- package/src/tags/registry.js +18 -18
- package/src/tags/util.js +400 -400
- package/src/types.d.ts +107 -107
- package/syntaxes/language-configuration.json +26 -26
- package/syntaxes/miki-template.tmLanguage.json +146 -146
- package/tests/asyncRender.test.js +17 -17
- package/tests/base.html +6 -6
- package/tests/child.html +3 -3
- package/tests/context_processors.test.js +13 -13
- package/tests/esm.test.mjs +61 -61
- package/tests/filters.test.js +254 -254
- package/tests/finder-appdirs.test.js +19 -0
- package/tests/finder.test.js +17 -0
- package/tests/fixtures/views/nested/index.html +1 -0
- package/tests/fixtures/views/partial.html +1 -0
- package/tests/fixtures/views/sub/deepfile.html +1 -0
- package/tests/fixtures/views-appdirs/product/site/detail.html +1 -0
- package/tests/include_security.test.js +9 -9
- package/tests/integration/README.md +32 -32
- package/tests/integration/features.test.cjs +1681 -1681
- package/tests/integration/features.test.mjs +1697 -1697
- package/tests/integration/finder.esm.test.mjs +13 -0
- package/tests/integration/templates/base.miki +6 -6
- package/tests/integration/templates/child.miki +6 -6
- package/tests/integration/templates/index.html +17 -17
- package/tests/lexer.test.js +45 -45
- package/tests/parser.test.js +57 -57
- package/tests/partial.html +1 -1
- package/tests/partialdef.test.js +79 -79
- package/tests/production_checks.js +57 -57
- package/tests/security.test.js +28 -28
- package/tests/tags.test.js +233 -233
package/src/libraries.js
CHANGED
|
@@ -1,371 +1,371 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Plugin/filter library system.
|
|
3
|
-
*
|
|
4
|
-
* A "library" is a JavaScript object exporting:
|
|
5
|
-
* { tags?: { [name]: parserFn }, filters?: { [name]: filterFn } }
|
|
6
|
-
*
|
|
7
|
-
* Libraries can be:
|
|
8
|
-
* - Built-in (e.g. 'i18n', 'cache', 'humanize', 'markdown')
|
|
9
|
-
* - Custom user-defined via `registerLibrary(name, lib)`
|
|
10
|
-
* - Loaded from disk via `registerLibraryFromPath(name, path)`
|
|
11
|
-
*
|
|
12
|
-
* Once registered, a library is activated in templates via:
|
|
13
|
-
* {% load library_name %}
|
|
14
|
-
* {% load i18n cache humanize %}
|
|
15
|
-
*/
|
|
16
|
-
const fs = require('fs');
|
|
17
|
-
const path = require('path');
|
|
18
|
-
|
|
19
|
-
const libraries = new Map(); // Map<name, libraryDef>
|
|
20
|
-
|
|
21
|
-
// Registration functions are injected by index.js to break the
|
|
22
|
-
// circular dependency between libraries.js and index.js.
|
|
23
|
-
let registrationFunctions = null;
|
|
24
|
-
function setRegistrationFunctions(fns) {
|
|
25
|
-
registrationFunctions = fns;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Register a library.
|
|
30
|
-
*
|
|
31
|
-
* registerLibrary('markdown', {
|
|
32
|
-
* tags: { markdown: parseMarkdown },
|
|
33
|
-
* filters: { markdown: renderMarkdown }
|
|
34
|
-
* });
|
|
35
|
-
*/
|
|
36
|
-
function registerLibrary(name, def) {
|
|
37
|
-
if (!name || typeof name !== 'string') {
|
|
38
|
-
throw new TypeError('Library name must be a non-empty string');
|
|
39
|
-
}
|
|
40
|
-
if (!def || typeof def !== 'object') {
|
|
41
|
-
throw new TypeError(`Library '${name}' definition must be an object`);
|
|
42
|
-
}
|
|
43
|
-
libraries.set(name, {
|
|
44
|
-
tags: def.tags || {},
|
|
45
|
-
filters: def.filters || {},
|
|
46
|
-
helpers: def.helpers || {}
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Unregister a library or all libraries. */
|
|
51
|
-
function unregisterLibrary(name) {
|
|
52
|
-
if (!name) {
|
|
53
|
-
libraries.clear();
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
libraries.delete(name);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Get a registered library by name. */
|
|
60
|
-
function getLibrary(name) {
|
|
61
|
-
return libraries.get(name);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** List all registered library names. */
|
|
65
|
-
function getLibraryNames() {
|
|
66
|
-
return Array.from(libraries.keys());
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Load a library from a JavaScript file on disk.
|
|
71
|
-
* The file must be a CommonJS module exporting `{ tags, filters, helpers }`.
|
|
72
|
-
*/
|
|
73
|
-
function registerLibraryFromPath(name, filePath, options = {}) {
|
|
74
|
-
const absolutePath = path.isAbsolute(filePath)
|
|
75
|
-
? filePath
|
|
76
|
-
: path.resolve(options.baseDir || process.cwd(), filePath);
|
|
77
|
-
if (!fs.existsSync(absolutePath)) {
|
|
78
|
-
throw new Error(`Library file not found: ${absolutePath}`);
|
|
79
|
-
}
|
|
80
|
-
// Defensive: restrict to .js/.cjs/.mjs files
|
|
81
|
-
const ext = path.extname(absolutePath).toLowerCase();
|
|
82
|
-
if (ext !== '.js' && ext !== '.cjs' && ext !== '.mjs') {
|
|
83
|
-
throw new Error(`Library file extension '${ext}' is not allowed`);
|
|
84
|
-
}
|
|
85
|
-
// Clear require cache if not pinned
|
|
86
|
-
if (!options.pinned) {
|
|
87
|
-
delete require.cache[require.resolve(absolutePath)];
|
|
88
|
-
}
|
|
89
|
-
const def = require(absolutePath);
|
|
90
|
-
registerLibrary(name, def);
|
|
91
|
-
return def;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Apply the tags, filters, and helpers from a library to the runtime registries.
|
|
96
|
-
*/
|
|
97
|
-
function activateLibrary(name) {
|
|
98
|
-
const lib = libraries.get(name);
|
|
99
|
-
if (!lib) {
|
|
100
|
-
throw new Error(`Library not found: '${name}'`);
|
|
101
|
-
}
|
|
102
|
-
// Require lazily; index.js will pass the real registration
|
|
103
|
-
// functions via setRegistrationFunctions() before calling us.
|
|
104
|
-
const { registerTag, registerFilter, registerHelper } = registrationFunctions || require('./index');
|
|
105
|
-
for (const [tagName, parserFn] of Object.entries(lib.tags || {})) {
|
|
106
|
-
registerTag(tagName, parserFn);
|
|
107
|
-
}
|
|
108
|
-
for (const [filterName, filterFn] of Object.entries(lib.filters || {})) {
|
|
109
|
-
registerFilter(filterName, filterFn);
|
|
110
|
-
}
|
|
111
|
-
for (const [helperName, helperFn] of Object.entries(lib.helpers || {})) {
|
|
112
|
-
registerHelper(helperName, helperFn);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Check if a library is registered.
|
|
118
|
-
*/
|
|
119
|
-
function hasLibrary(name) {
|
|
120
|
-
return libraries.has(name);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// --- Built-in libraries ---
|
|
124
|
-
|
|
125
|
-
/** Humanize: nice-looking text formatting filters. */
|
|
126
|
-
registerLibrary('humanize', {
|
|
127
|
-
filters: {
|
|
128
|
-
intcomma: (val) => {
|
|
129
|
-
const parts = String(val === null || val === undefined ? '' : val).split('.');
|
|
130
|
-
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
131
|
-
return parts.join('.');
|
|
132
|
-
},
|
|
133
|
-
intword: (val) => {
|
|
134
|
-
const n = parseFloat(val);
|
|
135
|
-
if (isNaN(n)) return String(val);
|
|
136
|
-
const abs = Math.abs(n);
|
|
137
|
-
if (abs >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, '') + ' billion';
|
|
138
|
-
if (abs >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + ' million';
|
|
139
|
-
if (abs >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + ' thousand';
|
|
140
|
-
return String(n);
|
|
141
|
-
},
|
|
142
|
-
apnumber: (val) => {
|
|
143
|
-
const n = parseInt(val, 10);
|
|
144
|
-
const words = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten',
|
|
145
|
-
'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'];
|
|
146
|
-
if (!isNaN(n) && n >= 0 && n < words.length) return words[n];
|
|
147
|
-
return String(val);
|
|
148
|
-
},
|
|
149
|
-
ordinal: (val) => {
|
|
150
|
-
const n = parseInt(val, 10);
|
|
151
|
-
if (isNaN(n)) return String(val);
|
|
152
|
-
const s = ['th', 'st', 'nd', 'rd'];
|
|
153
|
-
const v = n % 100;
|
|
154
|
-
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
|
155
|
-
},
|
|
156
|
-
naturalday: (val) => {
|
|
157
|
-
const d = new Date(val);
|
|
158
|
-
if (isNaN(d.getTime())) return String(val);
|
|
159
|
-
const today = new Date();
|
|
160
|
-
const yesterday = new Date(today);
|
|
161
|
-
yesterday.setDate(today.getDate() - 1);
|
|
162
|
-
const tomorrow = new Date(today);
|
|
163
|
-
tomorrow.setDate(today.getDate() + 1);
|
|
164
|
-
const sameDay = (a, b) =>
|
|
165
|
-
a.getFullYear() === b.getFullYear() &&
|
|
166
|
-
a.getMonth() === b.getMonth() &&
|
|
167
|
-
a.getDate() === b.getDate();
|
|
168
|
-
if (sameDay(d, today)) return 'today';
|
|
169
|
-
if (sameDay(d, yesterday)) return 'yesterday';
|
|
170
|
-
if (sameDay(d, tomorrow)) return 'tomorrow';
|
|
171
|
-
return String(val);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
/** Cache: cache expensive template fragment results. */
|
|
177
|
-
registerLibrary('cache', {
|
|
178
|
-
tags: {
|
|
179
|
-
cache: (tagContent, parser) => {
|
|
180
|
-
// {% cache timeout key1 key2 ... %}...{% endcache %}
|
|
181
|
-
const rest = tagContent.slice(5).trim();
|
|
182
|
-
const tokens = rest.split(/\s+/);
|
|
183
|
-
const timeout = parseInt(tokens[0], 10) || 0;
|
|
184
|
-
const key = tokens.slice(1).join(':') || 'default';
|
|
185
|
-
const body = parser.parse(['endcache']);
|
|
186
|
-
const next = parser.peek();
|
|
187
|
-
if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endcache') {
|
|
188
|
-
parser.advance();
|
|
189
|
-
}
|
|
190
|
-
return {
|
|
191
|
-
render(context) {
|
|
192
|
-
const ctx = context;
|
|
193
|
-
const fullKey = `cache:${key}`;
|
|
194
|
-
if (ctx.cacheStore && ctx.cacheStore.has(fullKey)) {
|
|
195
|
-
const entry = ctx.cacheStore.get(fullKey);
|
|
196
|
-
if (Date.now() - entry.time < timeout * 1000) {
|
|
197
|
-
return entry.value;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
const value = body.map(n => n.render(context)).join('');
|
|
201
|
-
if (ctx.cacheStore) {
|
|
202
|
-
ctx.cacheStore.set(fullKey, { time: Date.now(), value });
|
|
203
|
-
}
|
|
204
|
-
return value;
|
|
205
|
-
}
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
/** Lorem: generate placeholder text (Django-compatible). */
|
|
212
|
-
registerLibrary('lorem', {
|
|
213
|
-
filters: {
|
|
214
|
-
lorem: (val, arg) => {
|
|
215
|
-
let n = parseInt(arg, 10);
|
|
216
|
-
if (isNaN(n)) n = 5;
|
|
217
|
-
if (n < 1) n = 1;
|
|
218
|
-
if (n > 100) n = 100;
|
|
219
|
-
const text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.';
|
|
220
|
-
const words = text.split(' ');
|
|
221
|
-
let result = [];
|
|
222
|
-
for (let i = 0; i < n; i++) {
|
|
223
|
-
result.push(words[i % words.length]);
|
|
224
|
-
}
|
|
225
|
-
return result.join(' ');
|
|
226
|
-
}
|
|
227
|
-
},
|
|
228
|
-
tags: {
|
|
229
|
-
// Django-compatible lorem tag.
|
|
230
|
-
//
|
|
231
|
-
// {% lorem [count] [method] [random] %}
|
|
232
|
-
//
|
|
233
|
-
// count - number of words/paragraphs to generate (default 1)
|
|
234
|
-
// method - w (words), p (HTML paragraphs), b (plain-text blocks, default)
|
|
235
|
-
// random - skip the common "Lorem ipsum dolor sit amet..." opening
|
|
236
|
-
//
|
|
237
|
-
// {% lorem %} -> one block of common Lorem ipsum
|
|
238
|
-
// {% lorem 3 p %} -> common paragraph + 2 random <p> blocks
|
|
239
|
-
// {% lorem 2 w random %} -> 2 random words
|
|
240
|
-
//
|
|
241
|
-
// The tag is built into the engine and requires no {% load lorem %}.
|
|
242
|
-
lorem: (tagContent, _parser) => {
|
|
243
|
-
return {
|
|
244
|
-
render: () => {
|
|
245
|
-
// The tag registry calls the parser with the full content
|
|
246
|
-
// including the tag name. For "{% lorem 3 p random %}" the
|
|
247
|
-
// tagContent is "lorem 3 p random".
|
|
248
|
-
const parts = tagContent.trim().split(/\s+/);
|
|
249
|
-
|
|
250
|
-
// count = first numeric token (or 1 if none)
|
|
251
|
-
let n = 1;
|
|
252
|
-
for (let i = 0; i < parts.length; i++) {
|
|
253
|
-
const num = parseInt(parts[i], 10);
|
|
254
|
-
if (!isNaN(num) && String(num) === parts[i]) { n = Math.max(1, num); break; }
|
|
255
|
-
}
|
|
256
|
-
// method = w | b | p (default b, like Django)
|
|
257
|
-
let unit = 'b';
|
|
258
|
-
for (let i = 0; i < parts.length; i++) {
|
|
259
|
-
if (/^(w|b|p|words?|blocks?|paragraphs?)$/i.test(parts[i])) {
|
|
260
|
-
unit = parts[i][0].toLowerCase();
|
|
261
|
-
break;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
// random = presence of the literal word "random"
|
|
265
|
-
const random = parts.some(t => t.toLowerCase() === 'random');
|
|
266
|
-
|
|
267
|
-
// The famous "common paragraph" Django uses as the seed.
|
|
268
|
-
const COMMON = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';
|
|
269
|
-
// Word bank used to assemble random text when the "random"
|
|
270
|
-
// keyword is given. Picked from the same Latin vocabulary.
|
|
271
|
-
const WORDS = ('lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo consequat duis aute irure in voluptate velit esse cillum fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt culpa qui officia deserunt mollit anim id est laborum').split(' ');
|
|
272
|
-
|
|
273
|
-
// The non-random blocks cycle through a fixed set of
|
|
274
|
-
// sentences taken from the COMMON paragraph above (the
|
|
275
|
-
// first one is the "Lorem ipsum dolor sit amet" line).
|
|
276
|
-
const SENTENCES = [
|
|
277
|
-
'Lorem ipsum dolor sit amet, consectetur adipisicing elit.',
|
|
278
|
-
'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
|
279
|
-
'Ut enim ad minim veniam, quis nostrud exercitation.',
|
|
280
|
-
'Duis aute irure dolor in reprehenderit in voluptate velit.',
|
|
281
|
-
'Excepteur sint occaecat cupidatat non proident.'
|
|
282
|
-
];
|
|
283
|
-
|
|
284
|
-
if (unit === 'w') {
|
|
285
|
-
// Words
|
|
286
|
-
if (random) {
|
|
287
|
-
const out = [];
|
|
288
|
-
for (let i = 0; i < n; i++) {
|
|
289
|
-
out.push(WORDS[Math.floor(Math.random() * WORDS.length)]);
|
|
290
|
-
}
|
|
291
|
-
return out.join(' ');
|
|
292
|
-
}
|
|
293
|
-
// Non-random: first word is always "lorem", the rest
|
|
294
|
-
// continue the common paragraph.
|
|
295
|
-
const commonWords = COMMON.replace(/[.,]/g, '').split(/\s+/);
|
|
296
|
-
const out = [];
|
|
297
|
-
for (let i = 0; i < n; i++) {
|
|
298
|
-
out.push(commonWords[i % commonWords.length]);
|
|
299
|
-
}
|
|
300
|
-
return out.join(' ');
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
if (unit === 'p') {
|
|
304
|
-
// HTML paragraphs (each wrapped in <p>...</p>)
|
|
305
|
-
const out = [];
|
|
306
|
-
for (let i = 0; i < n; i++) {
|
|
307
|
-
let paragraph;
|
|
308
|
-
if (i === 0 && !random) {
|
|
309
|
-
paragraph = COMMON;
|
|
310
|
-
} else if (random) {
|
|
311
|
-
// Generate a sentence worth of random words
|
|
312
|
-
const len = 12 + Math.floor(Math.random() * 16);
|
|
313
|
-
const words = [];
|
|
314
|
-
for (let j = 0; j < len; j++) {
|
|
315
|
-
words.push(WORDS[Math.floor(Math.random() * WORDS.length)]);
|
|
316
|
-
}
|
|
317
|
-
paragraph = words.join(' ') + '.';
|
|
318
|
-
} else {
|
|
319
|
-
paragraph = SENTENCES[(i - 1) % (SENTENCES.length - 1) + 1];
|
|
320
|
-
}
|
|
321
|
-
out.push('<p>' + paragraph + '</p>');
|
|
322
|
-
}
|
|
323
|
-
return out.join('\n');
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// unit === 'b' (default): plain-text blocks separated by blank lines
|
|
327
|
-
const out = [];
|
|
328
|
-
for (let i = 0; i < n; i++) {
|
|
329
|
-
let paragraph;
|
|
330
|
-
if (i === 0 && !random) {
|
|
331
|
-
paragraph = COMMON;
|
|
332
|
-
} else if (random) {
|
|
333
|
-
const len = 30 + Math.floor(Math.random() * 20);
|
|
334
|
-
const words = [];
|
|
335
|
-
for (let j = 0; j < len; j++) {
|
|
336
|
-
words.push(WORDS[Math.floor(Math.random() * WORDS.length)]);
|
|
337
|
-
}
|
|
338
|
-
paragraph = words.join(' ') + '.';
|
|
339
|
-
} else {
|
|
340
|
-
paragraph = SENTENCES[(i - 1) % (SENTENCES.length - 1) + 1];
|
|
341
|
-
}
|
|
342
|
-
out.push(paragraph);
|
|
343
|
-
}
|
|
344
|
-
return out.join('\n\n');
|
|
345
|
-
}
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
/** Static: additional static-file helpers. */
|
|
352
|
-
registerLibrary('static', {
|
|
353
|
-
filters: {
|
|
354
|
-
static: (val) => {
|
|
355
|
-
// Returns the value prefixed with the staticUrl (configured at compile time)
|
|
356
|
-
return val; // Implementation lives in the {% static %} tag
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
module.exports = {
|
|
362
|
-
registerLibrary,
|
|
363
|
-
unregisterLibrary,
|
|
364
|
-
getLibrary,
|
|
365
|
-
getLibraryNames,
|
|
366
|
-
hasLibrary,
|
|
367
|
-
registerLibraryFromPath,
|
|
368
|
-
activateLibrary,
|
|
369
|
-
setRegistrationFunctions,
|
|
370
|
-
libraries
|
|
371
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* Plugin/filter library system.
|
|
3
|
+
*
|
|
4
|
+
* A "library" is a JavaScript object exporting:
|
|
5
|
+
* { tags?: { [name]: parserFn }, filters?: { [name]: filterFn } }
|
|
6
|
+
*
|
|
7
|
+
* Libraries can be:
|
|
8
|
+
* - Built-in (e.g. 'i18n', 'cache', 'humanize', 'markdown')
|
|
9
|
+
* - Custom user-defined via `registerLibrary(name, lib)`
|
|
10
|
+
* - Loaded from disk via `registerLibraryFromPath(name, path)`
|
|
11
|
+
*
|
|
12
|
+
* Once registered, a library is activated in templates via:
|
|
13
|
+
* {% load library_name %}
|
|
14
|
+
* {% load i18n cache humanize %}
|
|
15
|
+
*/
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const libraries = new Map(); // Map<name, libraryDef>
|
|
20
|
+
|
|
21
|
+
// Registration functions are injected by index.js to break the
|
|
22
|
+
// circular dependency between libraries.js and index.js.
|
|
23
|
+
let registrationFunctions = null;
|
|
24
|
+
function setRegistrationFunctions(fns) {
|
|
25
|
+
registrationFunctions = fns;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Register a library.
|
|
30
|
+
*
|
|
31
|
+
* registerLibrary('markdown', {
|
|
32
|
+
* tags: { markdown: parseMarkdown },
|
|
33
|
+
* filters: { markdown: renderMarkdown }
|
|
34
|
+
* });
|
|
35
|
+
*/
|
|
36
|
+
function registerLibrary(name, def) {
|
|
37
|
+
if (!name || typeof name !== 'string') {
|
|
38
|
+
throw new TypeError('Library name must be a non-empty string');
|
|
39
|
+
}
|
|
40
|
+
if (!def || typeof def !== 'object') {
|
|
41
|
+
throw new TypeError(`Library '${name}' definition must be an object`);
|
|
42
|
+
}
|
|
43
|
+
libraries.set(name, {
|
|
44
|
+
tags: def.tags || {},
|
|
45
|
+
filters: def.filters || {},
|
|
46
|
+
helpers: def.helpers || {}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Unregister a library or all libraries. */
|
|
51
|
+
function unregisterLibrary(name) {
|
|
52
|
+
if (!name) {
|
|
53
|
+
libraries.clear();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
libraries.delete(name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Get a registered library by name. */
|
|
60
|
+
function getLibrary(name) {
|
|
61
|
+
return libraries.get(name);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** List all registered library names. */
|
|
65
|
+
function getLibraryNames() {
|
|
66
|
+
return Array.from(libraries.keys());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Load a library from a JavaScript file on disk.
|
|
71
|
+
* The file must be a CommonJS module exporting `{ tags, filters, helpers }`.
|
|
72
|
+
*/
|
|
73
|
+
function registerLibraryFromPath(name, filePath, options = {}) {
|
|
74
|
+
const absolutePath = path.isAbsolute(filePath)
|
|
75
|
+
? filePath
|
|
76
|
+
: path.resolve(options.baseDir || process.cwd(), filePath);
|
|
77
|
+
if (!fs.existsSync(absolutePath)) {
|
|
78
|
+
throw new Error(`Library file not found: ${absolutePath}`);
|
|
79
|
+
}
|
|
80
|
+
// Defensive: restrict to .js/.cjs/.mjs files
|
|
81
|
+
const ext = path.extname(absolutePath).toLowerCase();
|
|
82
|
+
if (ext !== '.js' && ext !== '.cjs' && ext !== '.mjs') {
|
|
83
|
+
throw new Error(`Library file extension '${ext}' is not allowed`);
|
|
84
|
+
}
|
|
85
|
+
// Clear require cache if not pinned
|
|
86
|
+
if (!options.pinned) {
|
|
87
|
+
delete require.cache[require.resolve(absolutePath)];
|
|
88
|
+
}
|
|
89
|
+
const def = require(absolutePath);
|
|
90
|
+
registerLibrary(name, def);
|
|
91
|
+
return def;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Apply the tags, filters, and helpers from a library to the runtime registries.
|
|
96
|
+
*/
|
|
97
|
+
function activateLibrary(name) {
|
|
98
|
+
const lib = libraries.get(name);
|
|
99
|
+
if (!lib) {
|
|
100
|
+
throw new Error(`Library not found: '${name}'`);
|
|
101
|
+
}
|
|
102
|
+
// Require lazily; index.js will pass the real registration
|
|
103
|
+
// functions via setRegistrationFunctions() before calling us.
|
|
104
|
+
const { registerTag, registerFilter, registerHelper } = registrationFunctions || require('./index');
|
|
105
|
+
for (const [tagName, parserFn] of Object.entries(lib.tags || {})) {
|
|
106
|
+
registerTag(tagName, parserFn);
|
|
107
|
+
}
|
|
108
|
+
for (const [filterName, filterFn] of Object.entries(lib.filters || {})) {
|
|
109
|
+
registerFilter(filterName, filterFn);
|
|
110
|
+
}
|
|
111
|
+
for (const [helperName, helperFn] of Object.entries(lib.helpers || {})) {
|
|
112
|
+
registerHelper(helperName, helperFn);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check if a library is registered.
|
|
118
|
+
*/
|
|
119
|
+
function hasLibrary(name) {
|
|
120
|
+
return libraries.has(name);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- Built-in libraries ---
|
|
124
|
+
|
|
125
|
+
/** Humanize: nice-looking text formatting filters. */
|
|
126
|
+
registerLibrary('humanize', {
|
|
127
|
+
filters: {
|
|
128
|
+
intcomma: (val) => {
|
|
129
|
+
const parts = String(val === null || val === undefined ? '' : val).split('.');
|
|
130
|
+
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
131
|
+
return parts.join('.');
|
|
132
|
+
},
|
|
133
|
+
intword: (val) => {
|
|
134
|
+
const n = parseFloat(val);
|
|
135
|
+
if (isNaN(n)) return String(val);
|
|
136
|
+
const abs = Math.abs(n);
|
|
137
|
+
if (abs >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, '') + ' billion';
|
|
138
|
+
if (abs >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + ' million';
|
|
139
|
+
if (abs >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + ' thousand';
|
|
140
|
+
return String(n);
|
|
141
|
+
},
|
|
142
|
+
apnumber: (val) => {
|
|
143
|
+
const n = parseInt(val, 10);
|
|
144
|
+
const words = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten',
|
|
145
|
+
'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'];
|
|
146
|
+
if (!isNaN(n) && n >= 0 && n < words.length) return words[n];
|
|
147
|
+
return String(val);
|
|
148
|
+
},
|
|
149
|
+
ordinal: (val) => {
|
|
150
|
+
const n = parseInt(val, 10);
|
|
151
|
+
if (isNaN(n)) return String(val);
|
|
152
|
+
const s = ['th', 'st', 'nd', 'rd'];
|
|
153
|
+
const v = n % 100;
|
|
154
|
+
return n + (s[(v - 20) % 10] || s[v] || s[0]);
|
|
155
|
+
},
|
|
156
|
+
naturalday: (val) => {
|
|
157
|
+
const d = new Date(val);
|
|
158
|
+
if (isNaN(d.getTime())) return String(val);
|
|
159
|
+
const today = new Date();
|
|
160
|
+
const yesterday = new Date(today);
|
|
161
|
+
yesterday.setDate(today.getDate() - 1);
|
|
162
|
+
const tomorrow = new Date(today);
|
|
163
|
+
tomorrow.setDate(today.getDate() + 1);
|
|
164
|
+
const sameDay = (a, b) =>
|
|
165
|
+
a.getFullYear() === b.getFullYear() &&
|
|
166
|
+
a.getMonth() === b.getMonth() &&
|
|
167
|
+
a.getDate() === b.getDate();
|
|
168
|
+
if (sameDay(d, today)) return 'today';
|
|
169
|
+
if (sameDay(d, yesterday)) return 'yesterday';
|
|
170
|
+
if (sameDay(d, tomorrow)) return 'tomorrow';
|
|
171
|
+
return String(val);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
/** Cache: cache expensive template fragment results. */
|
|
177
|
+
registerLibrary('cache', {
|
|
178
|
+
tags: {
|
|
179
|
+
cache: (tagContent, parser) => {
|
|
180
|
+
// {% cache timeout key1 key2 ... %}...{% endcache %}
|
|
181
|
+
const rest = tagContent.slice(5).trim();
|
|
182
|
+
const tokens = rest.split(/\s+/);
|
|
183
|
+
const timeout = parseInt(tokens[0], 10) || 0;
|
|
184
|
+
const key = tokens.slice(1).join(':') || 'default';
|
|
185
|
+
const body = parser.parse(['endcache']);
|
|
186
|
+
const next = parser.peek();
|
|
187
|
+
if (next && next.type === 'block' && next.content.split(/\s+/)[0] === 'endcache') {
|
|
188
|
+
parser.advance();
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
render(context) {
|
|
192
|
+
const ctx = context;
|
|
193
|
+
const fullKey = `cache:${key}`;
|
|
194
|
+
if (ctx.cacheStore && ctx.cacheStore.has(fullKey)) {
|
|
195
|
+
const entry = ctx.cacheStore.get(fullKey);
|
|
196
|
+
if (Date.now() - entry.time < timeout * 1000) {
|
|
197
|
+
return entry.value;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const value = body.map(n => n.render(context)).join('');
|
|
201
|
+
if (ctx.cacheStore) {
|
|
202
|
+
ctx.cacheStore.set(fullKey, { time: Date.now(), value });
|
|
203
|
+
}
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/** Lorem: generate placeholder text (Django-compatible). */
|
|
212
|
+
registerLibrary('lorem', {
|
|
213
|
+
filters: {
|
|
214
|
+
lorem: (val, arg) => {
|
|
215
|
+
let n = parseInt(arg, 10);
|
|
216
|
+
if (isNaN(n)) n = 5;
|
|
217
|
+
if (n < 1) n = 1;
|
|
218
|
+
if (n > 100) n = 100;
|
|
219
|
+
const text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.';
|
|
220
|
+
const words = text.split(' ');
|
|
221
|
+
let result = [];
|
|
222
|
+
for (let i = 0; i < n; i++) {
|
|
223
|
+
result.push(words[i % words.length]);
|
|
224
|
+
}
|
|
225
|
+
return result.join(' ');
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
tags: {
|
|
229
|
+
// Django-compatible lorem tag.
|
|
230
|
+
//
|
|
231
|
+
// {% lorem [count] [method] [random] %}
|
|
232
|
+
//
|
|
233
|
+
// count - number of words/paragraphs to generate (default 1)
|
|
234
|
+
// method - w (words), p (HTML paragraphs), b (plain-text blocks, default)
|
|
235
|
+
// random - skip the common "Lorem ipsum dolor sit amet..." opening
|
|
236
|
+
//
|
|
237
|
+
// {% lorem %} -> one block of common Lorem ipsum
|
|
238
|
+
// {% lorem 3 p %} -> common paragraph + 2 random <p> blocks
|
|
239
|
+
// {% lorem 2 w random %} -> 2 random words
|
|
240
|
+
//
|
|
241
|
+
// The tag is built into the engine and requires no {% load lorem %}.
|
|
242
|
+
lorem: (tagContent, _parser) => {
|
|
243
|
+
return {
|
|
244
|
+
render: () => {
|
|
245
|
+
// The tag registry calls the parser with the full content
|
|
246
|
+
// including the tag name. For "{% lorem 3 p random %}" the
|
|
247
|
+
// tagContent is "lorem 3 p random".
|
|
248
|
+
const parts = tagContent.trim().split(/\s+/);
|
|
249
|
+
|
|
250
|
+
// count = first numeric token (or 1 if none)
|
|
251
|
+
let n = 1;
|
|
252
|
+
for (let i = 0; i < parts.length; i++) {
|
|
253
|
+
const num = parseInt(parts[i], 10);
|
|
254
|
+
if (!isNaN(num) && String(num) === parts[i]) { n = Math.max(1, num); break; }
|
|
255
|
+
}
|
|
256
|
+
// method = w | b | p (default b, like Django)
|
|
257
|
+
let unit = 'b';
|
|
258
|
+
for (let i = 0; i < parts.length; i++) {
|
|
259
|
+
if (/^(w|b|p|words?|blocks?|paragraphs?)$/i.test(parts[i])) {
|
|
260
|
+
unit = parts[i][0].toLowerCase();
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// random = presence of the literal word "random"
|
|
265
|
+
const random = parts.some(t => t.toLowerCase() === 'random');
|
|
266
|
+
|
|
267
|
+
// The famous "common paragraph" Django uses as the seed.
|
|
268
|
+
const COMMON = 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';
|
|
269
|
+
// Word bank used to assemble random text when the "random"
|
|
270
|
+
// keyword is given. Picked from the same Latin vocabulary.
|
|
271
|
+
const WORDS = ('lorem ipsum dolor sit amet consectetur adipisicing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua enim ad minim veniam quis nostrud exercitation ullamco laboris nisi aliquip ex ea commodo consequat duis aute irure in voluptate velit esse cillum fugiat nulla pariatur excepteur sint occaecat cupidatat non proident sunt culpa qui officia deserunt mollit anim id est laborum').split(' ');
|
|
272
|
+
|
|
273
|
+
// The non-random blocks cycle through a fixed set of
|
|
274
|
+
// sentences taken from the COMMON paragraph above (the
|
|
275
|
+
// first one is the "Lorem ipsum dolor sit amet" line).
|
|
276
|
+
const SENTENCES = [
|
|
277
|
+
'Lorem ipsum dolor sit amet, consectetur adipisicing elit.',
|
|
278
|
+
'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
|
279
|
+
'Ut enim ad minim veniam, quis nostrud exercitation.',
|
|
280
|
+
'Duis aute irure dolor in reprehenderit in voluptate velit.',
|
|
281
|
+
'Excepteur sint occaecat cupidatat non proident.'
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
if (unit === 'w') {
|
|
285
|
+
// Words
|
|
286
|
+
if (random) {
|
|
287
|
+
const out = [];
|
|
288
|
+
for (let i = 0; i < n; i++) {
|
|
289
|
+
out.push(WORDS[Math.floor(Math.random() * WORDS.length)]);
|
|
290
|
+
}
|
|
291
|
+
return out.join(' ');
|
|
292
|
+
}
|
|
293
|
+
// Non-random: first word is always "lorem", the rest
|
|
294
|
+
// continue the common paragraph.
|
|
295
|
+
const commonWords = COMMON.replace(/[.,]/g, '').split(/\s+/);
|
|
296
|
+
const out = [];
|
|
297
|
+
for (let i = 0; i < n; i++) {
|
|
298
|
+
out.push(commonWords[i % commonWords.length]);
|
|
299
|
+
}
|
|
300
|
+
return out.join(' ');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (unit === 'p') {
|
|
304
|
+
// HTML paragraphs (each wrapped in <p>...</p>)
|
|
305
|
+
const out = [];
|
|
306
|
+
for (let i = 0; i < n; i++) {
|
|
307
|
+
let paragraph;
|
|
308
|
+
if (i === 0 && !random) {
|
|
309
|
+
paragraph = COMMON;
|
|
310
|
+
} else if (random) {
|
|
311
|
+
// Generate a sentence worth of random words
|
|
312
|
+
const len = 12 + Math.floor(Math.random() * 16);
|
|
313
|
+
const words = [];
|
|
314
|
+
for (let j = 0; j < len; j++) {
|
|
315
|
+
words.push(WORDS[Math.floor(Math.random() * WORDS.length)]);
|
|
316
|
+
}
|
|
317
|
+
paragraph = words.join(' ') + '.';
|
|
318
|
+
} else {
|
|
319
|
+
paragraph = SENTENCES[(i - 1) % (SENTENCES.length - 1) + 1];
|
|
320
|
+
}
|
|
321
|
+
out.push('<p>' + paragraph + '</p>');
|
|
322
|
+
}
|
|
323
|
+
return out.join('\n');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// unit === 'b' (default): plain-text blocks separated by blank lines
|
|
327
|
+
const out = [];
|
|
328
|
+
for (let i = 0; i < n; i++) {
|
|
329
|
+
let paragraph;
|
|
330
|
+
if (i === 0 && !random) {
|
|
331
|
+
paragraph = COMMON;
|
|
332
|
+
} else if (random) {
|
|
333
|
+
const len = 30 + Math.floor(Math.random() * 20);
|
|
334
|
+
const words = [];
|
|
335
|
+
for (let j = 0; j < len; j++) {
|
|
336
|
+
words.push(WORDS[Math.floor(Math.random() * WORDS.length)]);
|
|
337
|
+
}
|
|
338
|
+
paragraph = words.join(' ') + '.';
|
|
339
|
+
} else {
|
|
340
|
+
paragraph = SENTENCES[(i - 1) % (SENTENCES.length - 1) + 1];
|
|
341
|
+
}
|
|
342
|
+
out.push(paragraph);
|
|
343
|
+
}
|
|
344
|
+
return out.join('\n\n');
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
/** Static: additional static-file helpers. */
|
|
352
|
+
registerLibrary('static', {
|
|
353
|
+
filters: {
|
|
354
|
+
static: (val) => {
|
|
355
|
+
// Returns the value prefixed with the staticUrl (configured at compile time)
|
|
356
|
+
return val; // Implementation lives in the {% static %} tag
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
module.exports = {
|
|
362
|
+
registerLibrary,
|
|
363
|
+
unregisterLibrary,
|
|
364
|
+
getLibrary,
|
|
365
|
+
getLibraryNames,
|
|
366
|
+
hasLibrary,
|
|
367
|
+
registerLibraryFromPath,
|
|
368
|
+
activateLibrary,
|
|
369
|
+
setRegistrationFunctions,
|
|
370
|
+
libraries
|
|
371
|
+
};
|