miki-template 1.3.6 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +16 -0
- package/.github/release-notes/v1.3.7.md +20 -0
- package/API_REFERENCE.md +27 -0
- package/README.md +10 -0
- package/benchmarks/report.json +3 -3
- package/docs/api.md +3 -0
- package/docs/overview.md +22 -0
- package/ex.mjs +4 -1
- package/live-test/package-lock.json +915 -0
- package/live-test/package.json +9 -0
- package/live-test/packages/product/templates/product/detail.html +7 -0
- package/live-test/server.js +38 -0
- package/live-test/templates/app_templates/detail.html +6 -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 +279 -7
- 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,38 @@
|
|
|
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
|
+
const templatesRoot = path.join(__dirname, 'templates');
|
|
8
|
+
|
|
9
|
+
// Demonstrate multiple discovery roots: explicit views dir,
|
|
10
|
+
// plus a top-level `templates/` (Django-style) and app-style nested folders
|
|
11
|
+
// discovered anywhere under the project root.
|
|
12
|
+
miki.setupExpress(app, { extension: 'html', views: [views, templatesRoot, __dirname] });
|
|
13
|
+
|
|
14
|
+
// Note: `miki.setupExpress` may expand `app.get('views')` to include
|
|
15
|
+
// nested template directories (project-level `templates/`,
|
|
16
|
+
// app-style `packages/*/templates/...`, etc.). The live-test app
|
|
17
|
+
// intentionally passed multiple roots to demonstrate discovery.
|
|
18
|
+
|
|
19
|
+
app.get('/', (req, res) => {
|
|
20
|
+
res.render('index', { name: 'Live NPM' });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Render a nested child template that extends base.html located in views/
|
|
24
|
+
app.get('/child', (req, res) => {
|
|
25
|
+
res.render('child', { title: 'From Live', message: 'Hello from child' });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Render a template located under templates/app_templates/detail.html
|
|
29
|
+
|
|
30
|
+
app.get('/app-detail', (req, res) => {
|
|
31
|
+
res.render('detail', { item: 'Widget 42' });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
app.get('/product', (req, res) => {
|
|
35
|
+
res.render('product/detail', { name: 'Gizmo', price: '$19.99' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
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": "
|
|
3
|
+
"version": "2.0.0",
|
|
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)}`
|
|
@@ -770,11 +894,80 @@ function setupExpress(app, opts = {}) {
|
|
|
770
894
|
// Install the raw engine so Express can use it
|
|
771
895
|
app.engine(ext, async ? __expressAsync : __express);
|
|
772
896
|
|
|
897
|
+
// Expand configured view roots to include nested directories that
|
|
898
|
+
// actually contain template files. Express will only check the
|
|
899
|
+
// directories returned by `app.get('views')`, so if templates live
|
|
900
|
+
// inside nested app folders (e.g. `packages/*/templates/...`), we
|
|
901
|
+
// must include those directories explicitly so `res.render('name')`
|
|
902
|
+
// succeeds without additional work by users.
|
|
903
|
+
function expandViewsForExpress(viewsInput, maxDepth = 6) {
|
|
904
|
+
const seen = new Set();
|
|
905
|
+
const results = [];
|
|
906
|
+
const roots = normalizeViews(viewsInput || (app.get && app.get('views')) || ['.']);
|
|
907
|
+
const isTemplateFile = name => /\.(html|miki)$/i.test(name);
|
|
908
|
+
|
|
909
|
+
for (const r of roots) {
|
|
910
|
+
let rootPath;
|
|
911
|
+
try { rootPath = path.resolve(r); } catch { continue; }
|
|
912
|
+
if (seen.has(rootPath)) continue;
|
|
913
|
+
seen.add(rootPath);
|
|
914
|
+
// BFS/DFS limited traversal
|
|
915
|
+
const stack = [{ dir: rootPath, depth: 0 }];
|
|
916
|
+
while (stack.length) {
|
|
917
|
+
const cur = stack.pop();
|
|
918
|
+
let entries;
|
|
919
|
+
try { entries = fs.readdirSync(cur.dir, { withFileTypes: true }); } catch { continue; }
|
|
920
|
+
let hasTemplate = false;
|
|
921
|
+
for (const ent of entries) {
|
|
922
|
+
if (ent.isFile() && isTemplateFile(ent.name)) {
|
|
923
|
+
hasTemplate = true;
|
|
924
|
+
break;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
if (hasTemplate) {
|
|
928
|
+
if (!seen.has(cur.dir)) {
|
|
929
|
+
seen.add(cur.dir);
|
|
930
|
+
results.push(cur.dir);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
if (cur.depth < maxDepth) {
|
|
934
|
+
for (const ent of entries) {
|
|
935
|
+
if (!ent.isDirectory()) continue;
|
|
936
|
+
const name = ent.name;
|
|
937
|
+
if (name === 'node_modules' || name === '.git' || name === 'dist' || name === 'build') continue;
|
|
938
|
+
const child = path.join(cur.dir, name);
|
|
939
|
+
if (!seen.has(child)) stack.push({ dir: child, depth: cur.depth + 1 });
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
// Always include the root itself so existing layouts still work
|
|
944
|
+
if (!results.includes(rootPath)) results.unshift(rootPath);
|
|
945
|
+
}
|
|
946
|
+
return results;
|
|
947
|
+
}
|
|
948
|
+
|
|
773
949
|
// Capture config in a closure so patchedRender can use it even
|
|
774
950
|
// when called before the request handler runs.
|
|
775
951
|
const configExt = ext;
|
|
776
952
|
const configViews = opts.views;
|
|
777
953
|
|
|
954
|
+
// Expand and set the app 'views' so Express's resolver can find
|
|
955
|
+
// templates placed in nested directories. Only do this when a
|
|
956
|
+
// non-empty views value exists (we don't want to change defaults
|
|
957
|
+
// if user intentionally left it unset).
|
|
958
|
+
try {
|
|
959
|
+
const currentViews = app.get && app.get('views') ? app.get('views') : configViews || app.get && app.get('views');
|
|
960
|
+
if (currentViews) {
|
|
961
|
+
const expanded = expandViewsForExpress(currentViews);
|
|
962
|
+
if (expanded && expanded.length) {
|
|
963
|
+
app.set('views', expanded);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
} catch (e) {
|
|
967
|
+
// Don't crash setupExpress if view expansion fails; fallback to
|
|
968
|
+
// whatever the app already had configured.
|
|
969
|
+
}
|
|
970
|
+
|
|
778
971
|
// Capture the original res.render so we can dispatch on #partial
|
|
779
972
|
const originalRender = app.response.render;
|
|
780
973
|
app.response.render = function patchedRender(view, locals, callback) {
|
|
@@ -827,6 +1020,21 @@ function setupExpress(app, opts = {}) {
|
|
|
827
1020
|
break;
|
|
828
1021
|
}
|
|
829
1022
|
}
|
|
1023
|
+
// Fallback: recursive search for templates in subfolders. If
|
|
1024
|
+
// the configured views is an array, search all normalized
|
|
1025
|
+
// entries rather than only the first directory so app-style
|
|
1026
|
+
// templates under other roots are discovered.
|
|
1027
|
+
if (!filePath) {
|
|
1028
|
+
let searchDirs = [viewsDir];
|
|
1029
|
+
if (Array.isArray(viewsDir)) {
|
|
1030
|
+
searchDirs = normalizeViews(viewsDir);
|
|
1031
|
+
} else if (Array.isArray(configViews)) {
|
|
1032
|
+
// If setupExpress was given an array, include its normalized form
|
|
1033
|
+
searchDirs = normalizeViews(configViews);
|
|
1034
|
+
}
|
|
1035
|
+
const found = findTemplateInViews(fileName, searchDirs);
|
|
1036
|
+
if (found) filePath = found;
|
|
1037
|
+
}
|
|
830
1038
|
if (!filePath) {
|
|
831
1039
|
const err = new Error(
|
|
832
1040
|
`Failed to lookup view "${view}" in views directory "${viewsDir}"`
|
|
@@ -855,11 +1063,55 @@ function setupExpress(app, opts = {}) {
|
|
|
855
1063
|
}
|
|
856
1064
|
}
|
|
857
1065
|
|
|
858
|
-
// No partial selector: behave exactly like the original res.render
|
|
859
|
-
|
|
860
|
-
|
|
1066
|
+
// No partial selector: behave exactly like the original res.render.
|
|
1067
|
+
// If Express fails to locate the view (no recursive lookup),
|
|
1068
|
+
// fall back to our recursive/app-style finder and invoke the
|
|
1069
|
+
// engine directly with the resolved file path.
|
|
1070
|
+
try {
|
|
1071
|
+
if (cb) {
|
|
1072
|
+
return originalRender.call(this, view, opts, cb);
|
|
1073
|
+
}
|
|
1074
|
+
return originalRender.call(this, view, opts);
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
// Detect Express view-not-found error and attempt fallback
|
|
1077
|
+
if (err && typeof err.message === 'string' && err.message.includes('Failed to lookup view')) {
|
|
1078
|
+
// Determine normalized view roots to search. Prefer arrays
|
|
1079
|
+
// returned by app.get('views') (Express supports arrays).
|
|
1080
|
+
let roots = [];
|
|
1081
|
+
try {
|
|
1082
|
+
const appViews = this.req && this.req.app ? this.req.app.get('views') : null;
|
|
1083
|
+
if (appViews) roots = roots.concat(Array.isArray(appViews) ? normalizeViews(appViews) : [appViews]);
|
|
1084
|
+
} catch (e) {}
|
|
1085
|
+
if (configViews) roots = roots.concat(Array.isArray(configViews) ? normalizeViews(configViews) : [configViews]);
|
|
1086
|
+
// If expandViewsForExpress was used earlier, app.get('views')
|
|
1087
|
+
// may already be an expanded array of candidate dirs. Ensure
|
|
1088
|
+
// uniqueness and absolute resolution.
|
|
1089
|
+
roots = roots.filter(Boolean).map(r => path.resolve(r));
|
|
1090
|
+
roots = Array.from(new Set(roots));
|
|
1091
|
+
|
|
1092
|
+
// Try to find the template file using our recursive finder.
|
|
1093
|
+
const searchRoots = roots.length ? roots : [process.cwd()];
|
|
1094
|
+
const found = findTemplateInViews(view, searchRoots);
|
|
1095
|
+
if (found) {
|
|
1096
|
+
// Invoke the engine directly with the resolved file path.
|
|
1097
|
+
try {
|
|
1098
|
+
if (cb) return (__express)(found, opts, cb);
|
|
1099
|
+
return __express(found, opts, (err2, html) => {
|
|
1100
|
+
if (err2) {
|
|
1101
|
+
if (cb) return cb(err2);
|
|
1102
|
+
throw err2;
|
|
1103
|
+
}
|
|
1104
|
+
this.send(html);
|
|
1105
|
+
});
|
|
1106
|
+
} catch (e2) {
|
|
1107
|
+
if (cb) return cb(e2);
|
|
1108
|
+
throw e2;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
// Not our error or fallback failed — rethrow
|
|
1113
|
+
throw err;
|
|
861
1114
|
}
|
|
862
|
-
return originalRender.call(this, view, opts);
|
|
863
1115
|
};
|
|
864
1116
|
}
|
|
865
1117
|
|
|
@@ -910,6 +1162,22 @@ function expressPartialRenderer() {
|
|
|
910
1162
|
filePath = null;
|
|
911
1163
|
} catch { filePath = null; }
|
|
912
1164
|
}
|
|
1165
|
+
// Fallback: recursive search for templates in subfolders. Prefer
|
|
1166
|
+
// searching all normalized view roots so templates in sibling
|
|
1167
|
+
// folders (project-level `templates/`, packages/*/templates, etc.)
|
|
1168
|
+
// are found.
|
|
1169
|
+
if (!filePath) {
|
|
1170
|
+
let searchDirs = Array.isArray(viewsDir) ? normalizeViews(viewsDir) : [viewsDir];
|
|
1171
|
+
// Also include the app-level configured views array if present
|
|
1172
|
+
try {
|
|
1173
|
+
const appViews = req.app.get('views');
|
|
1174
|
+
if (Array.isArray(appViews)) {
|
|
1175
|
+
searchDirs = searchDirs.concat(normalizeViews(appViews));
|
|
1176
|
+
}
|
|
1177
|
+
} catch (e) {}
|
|
1178
|
+
const found = findTemplateInViews(fileName, searchDirs);
|
|
1179
|
+
if (found) filePath = found;
|
|
1180
|
+
}
|
|
913
1181
|
if (!filePath) {
|
|
914
1182
|
return res.status(404).send(
|
|
915
1183
|
`Template not found: '${fileName}' in '${viewsDir}'`
|
|
@@ -933,6 +1201,10 @@ function expressPartialRenderer() {
|
|
|
933
1201
|
}
|
|
934
1202
|
|
|
935
1203
|
module.exports = {
|
|
1204
|
+
// Export the finder to allow unit tests to call it directly
|
|
1205
|
+
findTemplateInViews,
|
|
1206
|
+
setAppTemplateDirNames,
|
|
1207
|
+
getAppTemplateDirNames,
|
|
936
1208
|
compile,
|
|
937
1209
|
render,
|
|
938
1210
|
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
|
+
});
|