miki-template 1.3.6 → 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/docs/overview.md +22 -0
- package/ex.mjs +2 -0
- 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/extension.js +3 -3
- package/package.json +12 -9
- package/src/esm.mjs +5 -0
- package/src/index.js +141 -3
- 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/integration/finder.esm.test.mjs +13 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const miki = require('miki-template');
|
|
4
|
+
|
|
5
|
+
const app = express();
|
|
6
|
+
const views = path.join(__dirname, 'views');
|
|
7
|
+
|
|
8
|
+
miki.setupExpress(app, { extension: 'html', views });
|
|
9
|
+
|
|
10
|
+
app.get('/', (req, res) => {
|
|
11
|
+
res.render('index', { name: 'Live NPM' });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
app.listen(3002, () => console.log('Live test app listening on 3002'));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<html><head><title>{{ name }}</title></head><body><h1>Hello {{ name }}</h1></body></html>
|
|
@@ -894,7 +894,7 @@ function activate(context) {
|
|
|
894
894
|
const selection = editor.selection;
|
|
895
895
|
const selectedText = editor.document.getText(selection);
|
|
896
896
|
editor.edit(editBuilder => {
|
|
897
|
-
editBuilder.replace(selection,
|
|
897
|
+
editBuilder.replace(selection, '{% block ${1:name} %}\n' + selectedText + '\n{% endblock %}');
|
|
898
898
|
});
|
|
899
899
|
}),
|
|
900
900
|
|
|
@@ -904,7 +904,7 @@ function activate(context) {
|
|
|
904
904
|
const selection = editor.selection;
|
|
905
905
|
const selectedText = editor.document.getText(selection);
|
|
906
906
|
editor.edit(editBuilder => {
|
|
907
|
-
editBuilder.replace(selection,
|
|
907
|
+
editBuilder.replace(selection, '{% for ${1:item} in ${2:items} %}\n' + selectedText + '\n{% endfor %}');
|
|
908
908
|
});
|
|
909
909
|
}),
|
|
910
910
|
|
|
@@ -914,7 +914,7 @@ function activate(context) {
|
|
|
914
914
|
const selection = editor.selection;
|
|
915
915
|
const selectedText = editor.document.getText(selection);
|
|
916
916
|
editor.edit(editBuilder => {
|
|
917
|
-
editBuilder.replace(selection,
|
|
917
|
+
editBuilder.replace(selection, '{% if ${1:condition} %}\n' + selectedText + '\n{% endif %}');
|
|
918
918
|
});
|
|
919
919
|
}),
|
|
920
920
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "miki-template",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.7",
|
|
4
4
|
"description": "Django-Style template engine for Node.js and Express",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -10,13 +10,6 @@
|
|
|
10
10
|
"default": "./src/index.js"
|
|
11
11
|
}
|
|
12
12
|
},
|
|
13
|
-
"scripts": {
|
|
14
|
-
"test": "node -e \"const fs=require('fs');const path=require('path');function walk(d){const out=[];for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name);if(e.isDirectory())out.push(...walk(p));else if(/\\\\.test\\\\.(cjs|mjs|js)$/.test(e.name))out.push(p);}return out;}const files=walk('tests');require('child_process').execSync('node --test '+files.join(' '),{stdio:'inherit'})\"",
|
|
15
|
-
"bench": "node benchmarks/run.js",
|
|
16
|
-
"release:patch": "npm version patch --no-git-tag-version -m \"chore(release): v%s\" && git push origin main",
|
|
17
|
-
"release:minor": "npm version minor --no-git-tag-version -m \"chore(release): v%s\" && git push origin main",
|
|
18
|
-
"release:major": "npm version major --no-git-tag-version -m \"chore(release): v%s\" && git push origin main"
|
|
19
|
-
},
|
|
20
13
|
"keywords": [
|
|
21
14
|
"django",
|
|
22
15
|
"template",
|
|
@@ -32,6 +25,16 @@
|
|
|
32
25
|
"he": "^1.2.0"
|
|
33
26
|
},
|
|
34
27
|
"devDependencies": {
|
|
35
|
-
"express": "^5.2.1"
|
|
28
|
+
"express": "^5.2.1",
|
|
29
|
+
"eslint": "^8.50.0"
|
|
30
|
+
}
|
|
31
|
+
,
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node -e \"const fs=require('fs');const path=require('path');function walk(d){const out=[];for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name);if(e.isDirectory())out.push(...walk(p));else if(/\\\\.test\\\\.(cjs|mjs|js)$/.test(e.name))out.push(p);}return out;}const files=walk('tests');require('child_process').execSync('node --test '+files.join(' '),{stdio:'inherit'})\"",
|
|
34
|
+
"lint": "eslint . -f unix",
|
|
35
|
+
"bench": "node benchmarks/run.js",
|
|
36
|
+
"release:patch": "npm version patch --no-git-tag-version -m \"chore(release): v%s\" && git push origin main",
|
|
37
|
+
"release:minor": "npm version minor --no-git-tag-version -m \"chore(release): v%s\" && git push origin main",
|
|
38
|
+
"release:major": "npm version major --no-git-tag-version -m \"chore(release): v%s\" && git push origin main"
|
|
36
39
|
}
|
|
37
40
|
}
|
package/src/esm.mjs
CHANGED
|
@@ -44,6 +44,11 @@ const {
|
|
|
44
44
|
escapeHtml
|
|
45
45
|
} = cjsModule;
|
|
46
46
|
|
|
47
|
+
// Re-export the finder for ESM tests
|
|
48
|
+
export const findTemplateInViews = cjsModule.findTemplateInViews;
|
|
49
|
+
export const setAppTemplateDirNames = cjsModule.setAppTemplateDirNames;
|
|
50
|
+
export const getAppTemplateDirNames = cjsModule.getAppTemplateDirNames;
|
|
51
|
+
|
|
47
52
|
export {
|
|
48
53
|
compile,
|
|
49
54
|
render,
|
package/src/index.js
CHANGED
|
@@ -69,6 +69,97 @@ function normalizeViews(views) {
|
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// Find a template file by name in the provided views directories.
|
|
73
|
+
// Supports searching recursively through subdirectories when the
|
|
74
|
+
// template name is a bare name (no path separators). Returns the
|
|
75
|
+
// absolute path to the first matching file, or null if not found.
|
|
76
|
+
function findTemplateInViews(templateName, viewsDirs) {
|
|
77
|
+
if (!templateName) return null;
|
|
78
|
+
const hasExt = /\.[a-z0-9]+$/i.test(templateName);
|
|
79
|
+
// Candidate basenames to look for when doing recursive search
|
|
80
|
+
const candidateBasenames = hasExt ? [path.basename(templateName)] : [path.basename(templateName) + '.html', path.basename(templateName) + '.miki'];
|
|
81
|
+
|
|
82
|
+
// Normalize templateName's separators to the platform so direct
|
|
83
|
+
// resolves work when callers use forward slashes on Windows.
|
|
84
|
+
const templateNameNorm = templateName.replace(/\//g, path.sep);
|
|
85
|
+
|
|
86
|
+
for (const dir of viewsDirs) {
|
|
87
|
+
// Also consider app-style 'templates' directories nested inside
|
|
88
|
+
// the views root (e.g. project/app/templates/...)
|
|
89
|
+
const appTemplateDirs = findTemplatesDirsUnder(dir);
|
|
90
|
+
const searchDirs = [dir, ...appTemplateDirs];
|
|
91
|
+
for (const sdir of searchDirs) {
|
|
92
|
+
// Try direct resolution: if caller provided a path (like "nested/index")
|
|
93
|
+
// resolve it relative to the search dir and try supported extensions.
|
|
94
|
+
const basePath = path.resolve(sdir, templateNameNorm);
|
|
95
|
+
if (hasExt) {
|
|
96
|
+
try { if (fs.existsSync(basePath)) return basePath; } catch {}
|
|
97
|
+
} else {
|
|
98
|
+
try { if (fs.existsSync(basePath + '.html')) return basePath + '.html'; } catch {}
|
|
99
|
+
try { if (fs.existsSync(basePath + '.miki')) return basePath + '.miki'; } catch {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// If templateName is a bare name (no path separators), search
|
|
103
|
+
// recursively under the search dir for matching filenames.
|
|
104
|
+
if (!templateName.includes('/') && !templateName.includes(path.sep)) {
|
|
105
|
+
const stack = [sdir];
|
|
106
|
+
while (stack.length) {
|
|
107
|
+
const cur = stack.pop();
|
|
108
|
+
let entries;
|
|
109
|
+
try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch (e) { continue; }
|
|
110
|
+
for (const ent of entries) {
|
|
111
|
+
const p = path.join(cur, ent.name);
|
|
112
|
+
if (ent.isDirectory()) {
|
|
113
|
+
stack.push(p);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (!ent.isFile()) continue;
|
|
117
|
+
const relative = path.relative(path.resolve(sdir), p);
|
|
118
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) continue;
|
|
119
|
+
for (const candBasename of candidateBasenames) {
|
|
120
|
+
if (ent.name === candBasename) return p;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Search for "app-style" templates directories under the provided
|
|
131
|
+
// views directories. Many projects place templates inside an app
|
|
132
|
+
// submodule under `appname/templates/...`. This helper will locate
|
|
133
|
+
// any `templates` directories and search them for candidates.
|
|
134
|
+
let appTemplateDirNames = ['templates'];
|
|
135
|
+
|
|
136
|
+
function setAppTemplateDirNames(names) {
|
|
137
|
+
if (!names) return;
|
|
138
|
+
if (Array.isArray(names)) appTemplateDirNames = names.slice();
|
|
139
|
+
else if (typeof names === 'string') appTemplateDirNames = [names];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getAppTemplateDirNames() {
|
|
143
|
+
return appTemplateDirNames.slice();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function findTemplatesDirsUnder(dir) {
|
|
147
|
+
const results = [];
|
|
148
|
+
const stack = [dir];
|
|
149
|
+
while (stack.length) {
|
|
150
|
+
const cur = stack.pop();
|
|
151
|
+
let entries;
|
|
152
|
+
try { entries = fs.readdirSync(cur, { withFileTypes: true }); } catch (e) { continue; }
|
|
153
|
+
for (const ent of entries) {
|
|
154
|
+
const p = path.join(cur, ent.name);
|
|
155
|
+
if (!ent.isDirectory()) continue;
|
|
156
|
+
if (appTemplateDirNames.includes(ent.name)) results.push(p);
|
|
157
|
+
stack.push(p);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return results;
|
|
161
|
+
}
|
|
162
|
+
|
|
72
163
|
// Inject the registration functions so libraries can activate without
|
|
73
164
|
// triggering a circular require. This must happen BEFORE the
|
|
74
165
|
// auto-activation loop below.
|
|
@@ -101,15 +192,28 @@ function readParentSource(parentName, viewsDirs) {
|
|
|
101
192
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
102
193
|
throw new Error(`Extends tag attempted path traversal outside allowed views: '${parentName}'`);
|
|
103
194
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
195
|
+
if (fs.existsSync(fullPath)) {
|
|
196
|
+
const fileContent = fs.readFileSync(fullPath, 'utf8');
|
|
197
|
+
// Populate the LRU cache and return the value
|
|
198
|
+
return getParentSource(key, () => fileContent);
|
|
199
|
+
}
|
|
107
200
|
} catch (e) {
|
|
108
201
|
if (e.message && e.message.startsWith('Extends tag attempted path traversal')) {
|
|
109
202
|
throw e;
|
|
110
203
|
}
|
|
111
204
|
}
|
|
112
205
|
}
|
|
206
|
+
|
|
207
|
+
// Fallback: try recursive search through subfolders in the provided
|
|
208
|
+
// views directories. This enables Django-like behavior where a
|
|
209
|
+
// template may be placed in a nested folder and referenced by name.
|
|
210
|
+
const found = findTemplateInViews(parentName, viewsDirs);
|
|
211
|
+
if (found) {
|
|
212
|
+
const key = path.dirname(found) + '\0' + parentName;
|
|
213
|
+
const fileContent = fs.readFileSync(found, 'utf8');
|
|
214
|
+
return getParentSource(key, () => fileContent);
|
|
215
|
+
}
|
|
216
|
+
|
|
113
217
|
throw new Error(`Template not found: '${parentName}' in directories ${JSON.stringify(viewsDirs)}`);
|
|
114
218
|
}
|
|
115
219
|
|
|
@@ -135,6 +239,18 @@ async function renderASTAsync(nodes, context) {
|
|
|
135
239
|
|
|
136
240
|
const fileContent = readParentSource(parentName, viewsDirs);
|
|
137
241
|
|
|
242
|
+
// If parent not found by direct resolution, try recursive search
|
|
243
|
+
// (search subfolders) using the new helper. This ensures extends
|
|
244
|
+
// can locate parent templates placed in nested directories.
|
|
245
|
+
if (!fileContent) {
|
|
246
|
+
const found = findTemplateInViews(parentName, viewsDirs);
|
|
247
|
+
if (found) {
|
|
248
|
+
const fileContent2 = fs.readFileSync(found, 'utf8');
|
|
249
|
+
const key = path.dirname(found) + '\0' + parentName;
|
|
250
|
+
return getParentSource(key, () => fileContent2);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
138
254
|
const parentTokens = tokenize(fileContent);
|
|
139
255
|
const parentParser = new Parser(parentTokens, getTagRegistry());
|
|
140
256
|
const parentNodes = parentParser.parse();
|
|
@@ -427,6 +543,14 @@ function renderPartialFromFile(fileName, partialName, contextObj, options) {
|
|
|
427
543
|
}
|
|
428
544
|
if (loaded) break;
|
|
429
545
|
}
|
|
546
|
+
// Fallback: try recursive search if not loaded
|
|
547
|
+
if (!loaded) {
|
|
548
|
+
const found = findTemplateInViews(fileName, viewsDirs);
|
|
549
|
+
if (found) {
|
|
550
|
+
fileContent = fs.readFileSync(found, 'utf8');
|
|
551
|
+
loaded = true;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
430
554
|
if (!loaded) {
|
|
431
555
|
throw new Error(
|
|
432
556
|
`Template not found: '${fileName}' in directories ${JSON.stringify(viewsDirs)}`
|
|
@@ -827,6 +951,11 @@ function setupExpress(app, opts = {}) {
|
|
|
827
951
|
break;
|
|
828
952
|
}
|
|
829
953
|
}
|
|
954
|
+
// Fallback: recursive search for templates in subfolders
|
|
955
|
+
if (!filePath) {
|
|
956
|
+
const found = findTemplateInViews(fileName, [viewsDir]);
|
|
957
|
+
if (found) filePath = found;
|
|
958
|
+
}
|
|
830
959
|
if (!filePath) {
|
|
831
960
|
const err = new Error(
|
|
832
961
|
`Failed to lookup view "${view}" in views directory "${viewsDir}"`
|
|
@@ -910,6 +1039,11 @@ function expressPartialRenderer() {
|
|
|
910
1039
|
filePath = null;
|
|
911
1040
|
} catch { filePath = null; }
|
|
912
1041
|
}
|
|
1042
|
+
// Fallback: recursive search for templates in subfolders
|
|
1043
|
+
if (!filePath) {
|
|
1044
|
+
const found = findTemplateInViews(fileName, Array.isArray(viewsDir) ? viewsDir : [viewsDir]);
|
|
1045
|
+
if (found) filePath = found;
|
|
1046
|
+
}
|
|
913
1047
|
if (!filePath) {
|
|
914
1048
|
return res.status(404).send(
|
|
915
1049
|
`Template not found: '${fileName}' in '${viewsDir}'`
|
|
@@ -933,6 +1067,10 @@ function expressPartialRenderer() {
|
|
|
933
1067
|
}
|
|
934
1068
|
|
|
935
1069
|
module.exports = {
|
|
1070
|
+
// Export the finder to allow unit tests to call it directly
|
|
1071
|
+
findTemplateInViews,
|
|
1072
|
+
setAppTemplateDirNames,
|
|
1073
|
+
getAppTemplateDirNames,
|
|
936
1074
|
compile,
|
|
937
1075
|
render,
|
|
938
1076
|
asyncRender,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { findTemplateInViews, setAppTemplateDirNames } = require('../src/index.js');
|
|
6
|
+
|
|
7
|
+
test('Finder honors custom app template dir names', () => {
|
|
8
|
+
const fixtures = path.join(__dirname, 'fixtures', 'views-appdirs');
|
|
9
|
+
// Create a small fixture structure
|
|
10
|
+
if (!fs.existsSync(path.join(fixtures, 'product', 'site'))){
|
|
11
|
+
fs.mkdirSync(path.join(fixtures, 'product', 'site'), { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
const fname = path.join(fixtures, 'product', 'site', 'detail.html');
|
|
14
|
+
fs.writeFileSync(fname, '<p>detail</p>');
|
|
15
|
+
|
|
16
|
+
setAppTemplateDirNames(['site']);
|
|
17
|
+
const found = findTemplateInViews('detail', [fixtures]);
|
|
18
|
+
assert.ok(found && fs.existsSync(found));
|
|
19
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { findTemplateInViews } = require('../src/index.js');
|
|
6
|
+
|
|
7
|
+
test('Template finder - nested subfolder', () => {
|
|
8
|
+
const views = [path.join(__dirname, 'fixtures', 'views')];
|
|
9
|
+
const found = findTemplateInViews('nested/index', views);
|
|
10
|
+
assert.ok(found && fs.existsSync(found));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('Template finder - bare name searches subfolders', () => {
|
|
14
|
+
const views = [path.join(__dirname, 'fixtures', 'views')];
|
|
15
|
+
const found = findTemplateInViews('deepfile', views);
|
|
16
|
+
assert.ok(found && fs.existsSync(found));
|
|
17
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<h1>Nested Index</h1>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<p>Included: {{ item }}</p>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<div>Deep file</div>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<p>detail</p>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import { findTemplateInViews } from '../../src/esm.mjs';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
test('ESM Template finder - nested index', () => {
|
|
10
|
+
const views = [path.join(__dirname, '..', 'fixtures', 'views')];
|
|
11
|
+
const found = findTemplateInViews('nested/index', views);
|
|
12
|
+
assert.ok(found && typeof found === 'string');
|
|
13
|
+
});
|