@ynode/squirrellyify 1.0.2 → 1.2.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/README.md +74 -8
- package/package.json +10 -6
- package/src/config.js +201 -0
- package/src/plugin.js +90 -151
- package/src/resolver.js +223 -0
- package/src/runtime-api.js +75 -0
- package/src/safety.js +27 -0
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ A simple and fast plugin for using the [Squirrelly](https://squirrelly.js.org/)
|
|
|
13
13
|
- 🐿️ **Modern Templating:** Full support for Squirrelly v9 features.
|
|
14
14
|
- ⚡ **High Performance:** Template caching is enabled by default in production.
|
|
15
15
|
- 📁 **Layouts & Partials:** Built-in support for layouts and shared partials.
|
|
16
|
-
- 🧬 **Encapsulation-Aware:**
|
|
16
|
+
- 🧬 **Encapsulation-Aware:** Supports Fastify encapsulation with scoped template settings.
|
|
17
17
|
- 🛡️ **Secure:** Protects against path traversal attacks in template names.
|
|
18
18
|
- 🔧 **Extensible:** Easily add custom Squirrelly helpers and filters.
|
|
19
19
|
|
|
@@ -81,11 +81,30 @@ You can pass an options object when registering the plugin.
|
|
|
81
81
|
| Option | Type | Default | Description |
|
|
82
82
|
| ------------------ | -------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
83
83
|
| `templates` | `string \| string[]` | `path.join(process.cwd(), "views")` | The directory or directories to search for page and layout templates. Searched in the provided order. |
|
|
84
|
-
| `partials` | `string \| string[]` | `[]` | The directory or directories for partial templates. All partials are loaded on startup and
|
|
84
|
+
| `partials` | `string \| string[]` | `[]` | The directory or directories for partial templates. All partials are loaded on startup and available by name. |
|
|
85
|
+
| `partialsRecursive` | `boolean` | `true` | If `true`, partials are loaded recursively from subdirectories. Names use forward slashes (for example, `emails/header`). |
|
|
86
|
+
| `partialsNamespace` | `boolean \| string` | `false` | Optional namespace prefix for partial names. Use `true` to prefix with each partials directory basename, or provide a custom string. |
|
|
85
87
|
| `layout` | `string` | `undefined` | The name of the default layout file to use (without extension). Can be overridden per-route. |
|
|
86
|
-
| `defaultExtension` | `string` | `"sqrl"` | The file extension for all template files.
|
|
88
|
+
| `defaultExtension` | `string` | `"sqrl"` | The file extension for all template files. Leading `.` is optional (for example, `"html"` or `".html"`). |
|
|
87
89
|
| `cache` | `boolean` | `NODE_ENV === "production"` | If `true`, compiled templates and resolved file paths will be cached in memory. |
|
|
88
|
-
| `sqrl` | `object` | `undefined` |
|
|
90
|
+
| `sqrl` | `object` | `undefined` | Squirrelly options. Supports `{ scope: "global" \| "scoped", config, helpers, filters }`. |
|
|
91
|
+
|
|
92
|
+
Runtime API after registration:
|
|
93
|
+
|
|
94
|
+
- `fastify.viewHelpers.define(name, fn)`, `fastify.viewHelpers.get(name)`, `fastify.viewHelpers.remove(name)`
|
|
95
|
+
- `fastify.viewFilters.define(name, fn)`, `fastify.viewFilters.get(name)`, `fastify.viewFilters.remove(name)`
|
|
96
|
+
- `fastify.viewPartials.define(name, templateOrFn)`, `fastify.viewPartials.get(name)`, `fastify.viewPartials.remove(name)`
|
|
97
|
+
- `fastify.viewCache.clear()`, `fastify.viewCache.stats()`
|
|
98
|
+
|
|
99
|
+
These APIs are scope-aware:
|
|
100
|
+
|
|
101
|
+
- In `global` mode they modify shared helpers/filters/partials.
|
|
102
|
+
- In `scoped` mode they only affect the current plugin registration scope.
|
|
103
|
+
|
|
104
|
+
The cache API is process-local and lets you invalidate compiled template/path caches at runtime when
|
|
105
|
+
`cache: true` is used.
|
|
106
|
+
|
|
107
|
+
Invalid option types are rejected at plugin registration time with descriptive errors.
|
|
89
108
|
|
|
90
109
|
## Advanced Usage
|
|
91
110
|
|
|
@@ -151,13 +170,14 @@ You can specify a layout in three ways (in order of precedence):
|
|
|
151
170
|
### Partials
|
|
152
171
|
|
|
153
172
|
Partials are reusable chunks of template code. Create a `partials` directory and place your files
|
|
154
|
-
there.
|
|
173
|
+
there. By default, partials are loaded recursively and registered by forward-slash path from the
|
|
174
|
+
partials directory root.
|
|
155
175
|
|
|
156
176
|
**`partials/user-card.sqrl`**
|
|
157
177
|
|
|
158
178
|
```html
|
|
159
179
|
<div class="card">
|
|
160
|
-
<h3>{{ it.name }}</
|
|
180
|
+
<h3>{{ it.name }}</h3>
|
|
161
181
|
<p>{{ it.email }}</p>
|
|
162
182
|
</div>
|
|
163
183
|
```
|
|
@@ -166,7 +186,7 @@ there. They will be automatically registered by their filename.
|
|
|
166
186
|
|
|
167
187
|
```html
|
|
168
188
|
<h1>Users</h1>
|
|
169
|
-
{{
|
|
189
|
+
{{@include('user-card', { name: 'John Doe', email: 'john@example.com' })/}}
|
|
170
190
|
```
|
|
171
191
|
|
|
172
192
|
**Register the `partials` directory:**
|
|
@@ -178,6 +198,42 @@ fastify.register(squirrellyify, {
|
|
|
178
198
|
});
|
|
179
199
|
```
|
|
180
200
|
|
|
201
|
+
Nested partials use forward-slash names:
|
|
202
|
+
|
|
203
|
+
```text
|
|
204
|
+
partials/
|
|
205
|
+
└── cards/
|
|
206
|
+
└── user-card.sqrl
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
```html
|
|
210
|
+
{{@include('cards/user-card', { name: 'John Doe', email: 'john@example.com' })/}}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
To disable recursive loading:
|
|
214
|
+
|
|
215
|
+
```javascript
|
|
216
|
+
fastify.register(squirrellyify, {
|
|
217
|
+
templates: "views",
|
|
218
|
+
partials: "partials",
|
|
219
|
+
partialsRecursive: false,
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
To namespace partial names:
|
|
224
|
+
|
|
225
|
+
```javascript
|
|
226
|
+
fastify.register(squirrellyify, {
|
|
227
|
+
templates: "views",
|
|
228
|
+
partials: "partials",
|
|
229
|
+
partialsNamespace: "shared",
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
```html
|
|
234
|
+
{{@include('shared/cards/user-card', { name: 'John Doe', email: 'john@example.com' })/}}
|
|
235
|
+
```
|
|
236
|
+
|
|
181
237
|
### Scoped Configuration (Encapsulation)
|
|
182
238
|
|
|
183
239
|
This plugin supports Fastify's encapsulation model. You can register it multiple times with
|
|
@@ -187,8 +243,10 @@ different settings for different route prefixes.
|
|
|
187
243
|
import Fastify from "fastify";
|
|
188
244
|
import squirrellyify from "@ynode/squirrellyify";
|
|
189
245
|
import path from "node:path";
|
|
246
|
+
import { fileURLToPath } from "node:url";
|
|
190
247
|
|
|
191
248
|
const fastify = Fastify();
|
|
249
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
192
250
|
|
|
193
251
|
// Register with default settings
|
|
194
252
|
fastify.register(squirrellyify, {
|
|
@@ -223,6 +281,14 @@ fastify.register(
|
|
|
223
281
|
|
|
224
282
|
You can extend Squirrelly with custom helper and filter functions via the `sqrl` option.
|
|
225
283
|
|
|
284
|
+
Use `sqrl.scope` to choose registration mode:
|
|
285
|
+
|
|
286
|
+
- `global` (default): helpers, filters, and partials are shared across plugin registrations.
|
|
287
|
+
- `scoped`: helpers, filters, and partials are isolated to each plugin registration.
|
|
288
|
+
|
|
289
|
+
You can also add/remove helpers and filters at runtime via `fastify.viewHelpers` and
|
|
290
|
+
`fastify.viewFilters`.
|
|
291
|
+
|
|
226
292
|
```javascript
|
|
227
293
|
fastify.register(squirrellyify, {
|
|
228
294
|
templates: "views",
|
|
@@ -243,4 +309,4 @@ fastify.register(squirrellyify, {
|
|
|
243
309
|
|
|
244
310
|
## License
|
|
245
311
|
|
|
246
|
-
This project is licensed under the [MIT
|
|
312
|
+
This project is licensed under the [MIT License](./LICENSE).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ynode/squirrellyify",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Fastify plugin for rendering Squirrelly templates.",
|
|
5
5
|
"main": "src/plugin.js",
|
|
6
6
|
"type": "module",
|
|
@@ -31,10 +31,11 @@
|
|
|
31
31
|
"eslint": "^9.37.0",
|
|
32
32
|
"eslint-config-prettier": "^10.1.8",
|
|
33
33
|
"eslint-plugin-prettier": "^5.5.4",
|
|
34
|
+
"fastify": "^5.8.1",
|
|
34
35
|
"globals": "^16.4.0",
|
|
36
|
+
"jsdoc": "^4.0.5",
|
|
35
37
|
"prettier": "^3.6.2",
|
|
36
|
-
"rimraf": "^6.0.1"
|
|
37
|
-
"yuidocjs": "^0.10.2"
|
|
38
|
+
"rimraf": "^6.0.1"
|
|
38
39
|
},
|
|
39
40
|
"scripts": {
|
|
40
41
|
"docs": "node scripts/gen-docs.mjs",
|
|
@@ -44,10 +45,12 @@
|
|
|
44
45
|
"format:check": "prettier --check .",
|
|
45
46
|
"lint": "eslint .",
|
|
46
47
|
"lint:fix": "eslint . --fix",
|
|
48
|
+
"test:integration": "node --test tests/**/*.test.js",
|
|
49
|
+
"test:staged": "node scripts/lint-staged.mjs",
|
|
47
50
|
"ver:preview": "npx autover --no-amend --dry-run --short",
|
|
48
51
|
"ver:apply": "npx autover --guard-unchanged --short",
|
|
49
|
-
"test": "
|
|
50
|
-
"prepublishOnly": "npm test
|
|
52
|
+
"test": "npm run lint && npm run test:integration",
|
|
53
|
+
"prepublishOnly": "npm test",
|
|
51
54
|
"postversion": "git push && git push --tags"
|
|
52
55
|
},
|
|
53
56
|
"publishConfig": {
|
|
@@ -61,7 +64,8 @@
|
|
|
61
64
|
"dependencies": {
|
|
62
65
|
"fastify-plugin": "^5.1.0"
|
|
63
66
|
},
|
|
64
|
-
"
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"fastify": "^5.0.0",
|
|
65
69
|
"squirrelly": "^9.1.0"
|
|
66
70
|
}
|
|
67
71
|
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import Sqrl from "squirrelly";
|
|
4
|
+
|
|
5
|
+
function isPlainObject(value) {
|
|
6
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isStringArray(value) {
|
|
10
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function assertOptionType(condition, message) {
|
|
14
|
+
if (!condition) {
|
|
15
|
+
throw new TypeError(message);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeDefaultExtension(defaultExtension) {
|
|
20
|
+
const normalized = defaultExtension.replace(/^\.+/, "");
|
|
21
|
+
if (normalized.length === 0) {
|
|
22
|
+
throw new TypeError(
|
|
23
|
+
'Invalid option "defaultExtension": must contain at least one non-dot character.',
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return normalized;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function validatePluginOptions(options = {}) {
|
|
30
|
+
assertOptionType(
|
|
31
|
+
isPlainObject(options),
|
|
32
|
+
"Invalid options: plugin options must be a plain object.",
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (options.templates !== undefined) {
|
|
36
|
+
assertOptionType(
|
|
37
|
+
typeof options.templates === "string" || isStringArray(options.templates),
|
|
38
|
+
'Invalid option "templates": expected a string or an array of strings.',
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (options.partials !== undefined) {
|
|
42
|
+
assertOptionType(
|
|
43
|
+
typeof options.partials === "string" || isStringArray(options.partials),
|
|
44
|
+
'Invalid option "partials": expected a string or an array of strings.',
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
if (options.partialsRecursive !== undefined) {
|
|
48
|
+
assertOptionType(
|
|
49
|
+
typeof options.partialsRecursive === "boolean",
|
|
50
|
+
'Invalid option "partialsRecursive": expected a boolean.',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (options.partialsNamespace !== undefined) {
|
|
54
|
+
assertOptionType(
|
|
55
|
+
typeof options.partialsNamespace === "boolean" ||
|
|
56
|
+
typeof options.partialsNamespace === "string",
|
|
57
|
+
'Invalid option "partialsNamespace": expected a boolean or a string.',
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (options.layout !== undefined) {
|
|
61
|
+
assertOptionType(typeof options.layout === "string", 'Invalid option "layout": expected a string.');
|
|
62
|
+
}
|
|
63
|
+
if (options.defaultExtension !== undefined) {
|
|
64
|
+
assertOptionType(
|
|
65
|
+
typeof options.defaultExtension === "string",
|
|
66
|
+
'Invalid option "defaultExtension": expected a string.',
|
|
67
|
+
);
|
|
68
|
+
normalizeDefaultExtension(options.defaultExtension);
|
|
69
|
+
}
|
|
70
|
+
if (options.cache !== undefined) {
|
|
71
|
+
assertOptionType(typeof options.cache === "boolean", 'Invalid option "cache": expected a boolean.');
|
|
72
|
+
}
|
|
73
|
+
if (options.sqrl !== undefined) {
|
|
74
|
+
assertOptionType(isPlainObject(options.sqrl), 'Invalid option "sqrl": expected an object.');
|
|
75
|
+
|
|
76
|
+
if (options.sqrl.scope !== undefined) {
|
|
77
|
+
assertOptionType(
|
|
78
|
+
options.sqrl.scope === "global" || options.sqrl.scope === "scoped",
|
|
79
|
+
'Invalid option "sqrl.scope": expected "global" or "scoped".',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (options.sqrl.config !== undefined) {
|
|
83
|
+
assertOptionType(
|
|
84
|
+
isPlainObject(options.sqrl.config),
|
|
85
|
+
'Invalid option "sqrl.config": expected an object.',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (options.sqrl.helpers !== undefined) {
|
|
89
|
+
assertOptionType(
|
|
90
|
+
isPlainObject(options.sqrl.helpers),
|
|
91
|
+
'Invalid option "sqrl.helpers": expected an object of functions.',
|
|
92
|
+
);
|
|
93
|
+
for (const [name, fn] of Object.entries(options.sqrl.helpers)) {
|
|
94
|
+
assertOptionType(
|
|
95
|
+
typeof fn === "function",
|
|
96
|
+
`Invalid option "sqrl.helpers.${name}": expected a function.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (options.sqrl.filters !== undefined) {
|
|
101
|
+
assertOptionType(
|
|
102
|
+
isPlainObject(options.sqrl.filters),
|
|
103
|
+
'Invalid option "sqrl.filters": expected an object of functions.',
|
|
104
|
+
);
|
|
105
|
+
for (const [name, fn] of Object.entries(options.sqrl.filters)) {
|
|
106
|
+
assertOptionType(
|
|
107
|
+
typeof fn === "function",
|
|
108
|
+
`Invalid option "sqrl.filters.${name}": expected a function.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function resolveInitialTemplateDirs(options = {}) {
|
|
116
|
+
return Array.isArray(options.templates)
|
|
117
|
+
? options.templates
|
|
118
|
+
: typeof options.templates === "string"
|
|
119
|
+
? [options.templates]
|
|
120
|
+
: [path.join(process.cwd(), "views")];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function resolveInitialPartialsDirs(options = {}) {
|
|
124
|
+
return Array.isArray(options.partials)
|
|
125
|
+
? options.partials
|
|
126
|
+
: typeof options.partials === "string"
|
|
127
|
+
? [options.partials]
|
|
128
|
+
: [];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function resolveExtension(options = {}) {
|
|
132
|
+
const defaultExtension =
|
|
133
|
+
options.defaultExtension !== undefined
|
|
134
|
+
? normalizeDefaultExtension(options.defaultExtension)
|
|
135
|
+
: "sqrl";
|
|
136
|
+
return {
|
|
137
|
+
defaultExtension,
|
|
138
|
+
extensionWithDot: `.${defaultExtension}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function resolveUseCache(options = {}) {
|
|
143
|
+
return options.cache ?? process.env.NODE_ENV === "production";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function createScopedSqrlConfig(baseConfig) {
|
|
147
|
+
const Cacher = Sqrl.helpers?.constructor;
|
|
148
|
+
if (typeof Cacher !== "function") {
|
|
149
|
+
throw new Error("Unable to initialize scoped Squirrelly storage.");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const scopedHelpers = new Cacher({});
|
|
153
|
+
const scopedFilters = new Cacher({});
|
|
154
|
+
const scopedTemplates = new Cacher({});
|
|
155
|
+
|
|
156
|
+
for (const helperName of [
|
|
157
|
+
"each",
|
|
158
|
+
"foreach",
|
|
159
|
+
"include",
|
|
160
|
+
"extends",
|
|
161
|
+
"useScope",
|
|
162
|
+
"includeFile",
|
|
163
|
+
"extendsFile",
|
|
164
|
+
]) {
|
|
165
|
+
const helperFn = Sqrl.helpers.get(helperName);
|
|
166
|
+
if (helperFn) {
|
|
167
|
+
scopedHelpers.define(helperName, helperFn);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const escapeFilter = Sqrl.filters.get("e");
|
|
172
|
+
if (escapeFilter) {
|
|
173
|
+
scopedFilters.define("e", escapeFilter);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return Sqrl.getConfig(
|
|
177
|
+
{
|
|
178
|
+
...baseConfig,
|
|
179
|
+
storage: {
|
|
180
|
+
helpers: scopedHelpers,
|
|
181
|
+
nativeHelpers: Sqrl.nativeHelpers,
|
|
182
|
+
filters: scopedFilters,
|
|
183
|
+
templates: scopedTemplates,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
Sqrl.defaultConfig,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function resolveSqrlConfig(options = {}) {
|
|
191
|
+
const sqrlScope = options.sqrl?.scope === "scoped" ? "scoped" : "global";
|
|
192
|
+
const sqrlConfig =
|
|
193
|
+
sqrlScope === "scoped"
|
|
194
|
+
? createScopedSqrlConfig(options.sqrl?.config)
|
|
195
|
+
: Sqrl.getConfig(options.sqrl?.config ?? {}, Sqrl.defaultConfig);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
sqrlScope,
|
|
199
|
+
sqrlConfig,
|
|
200
|
+
};
|
|
201
|
+
}
|
package/src/plugin.js
CHANGED
|
@@ -27,16 +27,30 @@ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
|
27
27
|
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import fs from "node:fs/promises";
|
|
31
|
-
import path from "node:path";
|
|
32
|
-
|
|
33
30
|
import fp from "fastify-plugin";
|
|
34
31
|
import Sqrl from "squirrelly";
|
|
35
32
|
|
|
33
|
+
import {
|
|
34
|
+
validatePluginOptions,
|
|
35
|
+
resolveExtension,
|
|
36
|
+
resolveInitialPartialsDirs,
|
|
37
|
+
resolveInitialTemplateDirs,
|
|
38
|
+
resolveSqrlConfig,
|
|
39
|
+
resolveUseCache,
|
|
40
|
+
} from "./config.js";
|
|
41
|
+
import {
|
|
42
|
+
buildTemplateSearchDirs,
|
|
43
|
+
collectViewScope,
|
|
44
|
+
createTemplateResolver,
|
|
45
|
+
preloadPartials,
|
|
46
|
+
} from "./resolver.js";
|
|
47
|
+
import { createRuntimeApi } from "./runtime-api.js";
|
|
48
|
+
import { assertSafeName } from "./safety.js";
|
|
49
|
+
|
|
36
50
|
/**
|
|
37
|
-
* @typedef {
|
|
38
|
-
* @typedef {
|
|
39
|
-
* @typedef {
|
|
51
|
+
* @typedef {object} FastifyInstance
|
|
52
|
+
* @typedef {object} FastifyReply
|
|
53
|
+
* @typedef {object} SqrlConfig
|
|
40
54
|
*/
|
|
41
55
|
|
|
42
56
|
/**
|
|
@@ -47,103 +61,66 @@ import Sqrl from "squirrelly";
|
|
|
47
61
|
* @param {object} options Plugin options.
|
|
48
62
|
* @param {string|string[]} [options.templates] The directory or directories where page and layout templates are stored. Defaults to "views". Directories are searched in order.
|
|
49
63
|
* @param {string|string[]} [options.partials] The directory or directories where partial templates are stored.
|
|
64
|
+
* @param {boolean} [options.partialsRecursive=true] Enables recursive loading of partial templates from subdirectories.
|
|
65
|
+
* @param {boolean|string} [options.partialsNamespace=false] Optional namespace prefix for partial names. Use `true` to namespace by partials directory basename.
|
|
50
66
|
* @param {string} [options.layout] The name of the default layout file to use (without extension).
|
|
51
67
|
* @param {string} [options.defaultExtension="sqrl"] The default extension for template files.
|
|
52
68
|
* @param {boolean} [options.cache] Enables template caching. Defaults to true if NODE_ENV is "production".
|
|
69
|
+
* @param {object} [options.sqrl] Squirrelly engine options.
|
|
70
|
+
* @param {"global"|"scoped"} [options.sqrl.scope="global"] Whether to share helpers/filters/partials globally or isolate them per Fastify registration.
|
|
71
|
+
* @param {SqrlConfig} [options.sqrl.config] Squirrelly compile/render config.
|
|
72
|
+
* @param {Record<string, Function>} [options.sqrl.helpers] Custom Squirrelly helpers.
|
|
73
|
+
* @param {Record<string, Function>} [options.sqrl.filters] Custom Squirrelly filters.
|
|
53
74
|
*/
|
|
54
75
|
async function squirrellyify(fastify, options = {}) {
|
|
55
|
-
|
|
56
|
-
const initialTemplatesDirs = Array.isArray(options.templates)
|
|
57
|
-
? options.templates
|
|
58
|
-
: typeof options.templates === "string"
|
|
59
|
-
? [options.templates]
|
|
60
|
-
: [path.join(process.cwd(), "views")];
|
|
61
|
-
|
|
62
|
-
const initialPartialsDirs = Array.isArray(options.partials)
|
|
63
|
-
? options.partials
|
|
64
|
-
: typeof options.partials === "string"
|
|
65
|
-
? [options.partials]
|
|
66
|
-
: [];
|
|
76
|
+
validatePluginOptions(options);
|
|
67
77
|
|
|
78
|
+
const initialTemplatesDirs = resolveInitialTemplateDirs(options);
|
|
79
|
+
const initialPartialsDirs = resolveInitialPartialsDirs(options);
|
|
68
80
|
const initialLayout = options.layout;
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
});
|
|
90
|
-
}
|
|
81
|
+
const { extensionWithDot } = resolveExtension(options);
|
|
82
|
+
const useCache = resolveUseCache(options);
|
|
83
|
+
const { sqrlScope, sqrlConfig } = resolveSqrlConfig(options);
|
|
84
|
+
const {
|
|
85
|
+
defineSqrlHelper,
|
|
86
|
+
defineSqrlFilter,
|
|
87
|
+
defineSqrlTemplate,
|
|
88
|
+
viewHelpers,
|
|
89
|
+
viewFilters,
|
|
90
|
+
viewPartials,
|
|
91
|
+
} =
|
|
92
|
+
createRuntimeApi({
|
|
93
|
+
sqrlScope,
|
|
94
|
+
sqrlConfig,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (options.sqrl?.helpers) {
|
|
98
|
+
Object.entries(options.sqrl.helpers).forEach(([name, fn]) => {
|
|
99
|
+
defineSqrlHelper(name, fn);
|
|
100
|
+
});
|
|
91
101
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
try {
|
|
97
|
-
const files = await fs.readdir(partialsDir);
|
|
98
|
-
await Promise.all(
|
|
99
|
-
files.map(async (file) => {
|
|
100
|
-
if (file.endsWith(extensionWithDot)) {
|
|
101
|
-
const partialPath = path.join(partialsDir, file);
|
|
102
|
-
const partialName = path.basename(file, extensionWithDot);
|
|
103
|
-
const content = await fs.readFile(partialPath, "utf-8");
|
|
104
|
-
fastify.log.trace(`Loaded partial: ${partialName}`);
|
|
105
|
-
Sqrl.templates.define(partialName, Sqrl.compile(content, sqrlConfig));
|
|
106
|
-
}
|
|
107
|
-
}),
|
|
108
|
-
);
|
|
109
|
-
} catch (error) {
|
|
110
|
-
fastify.log.error(`Error loading partials from ${partialsDir}: ${error.message}`);
|
|
111
|
-
throw error;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Compiles a template from a file path and caches it if enabled.
|
|
118
|
-
*/
|
|
119
|
-
async function getTemplate(templatePath) {
|
|
120
|
-
if (useCache && templateCache.has(templatePath)) {
|
|
121
|
-
return templateCache.get(templatePath);
|
|
122
|
-
}
|
|
123
|
-
const content = await fs.readFile(templatePath, "utf-8");
|
|
124
|
-
const hasLayoutTag = /{{\s*(?:@extends|!layout)\s*\(/.test(content);
|
|
125
|
-
const compiled = Sqrl.compile(content, sqrlConfig);
|
|
126
|
-
templateMeta.set(templatePath, { hasLayoutTag });
|
|
127
|
-
if (useCache) {
|
|
128
|
-
templateCache.set(templatePath, compiled);
|
|
129
|
-
}
|
|
130
|
-
return compiled;
|
|
102
|
+
if (options.sqrl?.filters) {
|
|
103
|
+
Object.entries(options.sqrl.filters).forEach(([name, fn]) => {
|
|
104
|
+
defineSqrlFilter(name, fn);
|
|
105
|
+
});
|
|
131
106
|
}
|
|
132
107
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
108
|
+
await preloadPartials({
|
|
109
|
+
partialsDirs: initialPartialsDirs,
|
|
110
|
+
extensionWithDot,
|
|
111
|
+
partialsRecursive: options.partialsRecursive ?? true,
|
|
112
|
+
partialsNamespace: options.partialsNamespace ?? false,
|
|
113
|
+
fastify,
|
|
114
|
+
defineSqrlTemplate,
|
|
115
|
+
sqrlConfig,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const { findTemplatePath, getTemplate, hasLayoutTag, clearCaches, cacheStats } = createTemplateResolver({
|
|
119
|
+
fastify,
|
|
120
|
+
extensionWithDot,
|
|
121
|
+
useCache,
|
|
122
|
+
sqrlConfig,
|
|
123
|
+
});
|
|
147
124
|
|
|
148
125
|
/**
|
|
149
126
|
* Renders a Squirrelly template and sends it as an HTML response.
|
|
@@ -159,66 +136,22 @@ async function squirrellyify(fastify, options = {}) {
|
|
|
159
136
|
}
|
|
160
137
|
|
|
161
138
|
const instance = this.request.server;
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (currentInstance.views) {
|
|
168
|
-
const dirs = Array.isArray(currentInstance.views)
|
|
169
|
-
? currentInstance.views
|
|
170
|
-
: [currentInstance.views];
|
|
171
|
-
aggregatedTemplatesDirs.push(...dirs);
|
|
172
|
-
}
|
|
173
|
-
if (
|
|
174
|
-
scopedLayout === null &&
|
|
175
|
-
currentInstance.layout !== null &&
|
|
176
|
-
currentInstance.layout !== undefined
|
|
177
|
-
) {
|
|
178
|
-
scopedLayout = currentInstance.layout;
|
|
179
|
-
}
|
|
180
|
-
// Defensive: parent may be undefined or private
|
|
181
|
-
currentInstance = currentInstance.parent ?? null;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const combinedDirs = [
|
|
185
|
-
...new Set([...aggregatedTemplatesDirs, ...initialTemplatesDirs]),
|
|
186
|
-
];
|
|
187
|
-
const allSearchDirs = [...new Set([...combinedDirs, ...initialPartialsDirs])];
|
|
188
|
-
|
|
189
|
-
async function findTemplatePath(templateName) {
|
|
190
|
-
const templateFile = `${templateName}${extensionWithDot}`;
|
|
191
|
-
const cacheKey = `${allSearchDirs.join(";")}:${templateFile}`; // Create a unique key
|
|
192
|
-
|
|
193
|
-
if (useCache && pathCache.has(cacheKey)) {
|
|
194
|
-
return pathCache.get(cacheKey);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
for (const dir of allSearchDirs) {
|
|
198
|
-
const fullPath = path.join(dir, templateFile);
|
|
199
|
-
try {
|
|
200
|
-
await fs.access(fullPath);
|
|
201
|
-
if (useCache) {
|
|
202
|
-
pathCache.set(cacheKey, fullPath); // Cache the found path
|
|
203
|
-
}
|
|
204
|
-
return fullPath;
|
|
205
|
-
} catch (error) {
|
|
206
|
-
fastify.log.trace(error);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return null;
|
|
210
|
-
}
|
|
139
|
+
const { aggregatedTemplatesDirs, scopedLayout } = collectViewScope(instance);
|
|
140
|
+
const templateSearchDirs = buildTemplateSearchDirs(
|
|
141
|
+
aggregatedTemplatesDirs,
|
|
142
|
+
initialTemplatesDirs,
|
|
143
|
+
);
|
|
211
144
|
|
|
212
145
|
// 1. Find and render the page template
|
|
213
|
-
const pagePath = await findTemplatePath(template);
|
|
146
|
+
const pagePath = await findTemplatePath(template, templateSearchDirs);
|
|
214
147
|
if (!pagePath) {
|
|
215
148
|
throw new Error(
|
|
216
|
-
`Template "${template}" not found in [${
|
|
149
|
+
`Template "${template}" not found in [${templateSearchDirs.join(", ")}]`,
|
|
217
150
|
);
|
|
218
151
|
}
|
|
219
152
|
|
|
220
153
|
const pageTemplate = await getTemplate(pagePath);
|
|
221
|
-
const pageHtml = pageTemplate(data, sqrlConfig
|
|
154
|
+
const pageHtml = await pageTemplate(data, sqrlConfig);
|
|
222
155
|
|
|
223
156
|
// 2. Determine which layout to use
|
|
224
157
|
const currentLayout = scopedLayout !== null ? scopedLayout : initialLayout;
|
|
@@ -228,26 +161,25 @@ async function squirrellyify(fastify, options = {}) {
|
|
|
228
161
|
return this.type("text/html").send(pageHtml);
|
|
229
162
|
}
|
|
230
163
|
|
|
231
|
-
|
|
232
|
-
if (hasLayoutTag) {
|
|
164
|
+
if (hasLayoutTag(pagePath)) {
|
|
233
165
|
return this.type("text/html").send(pageHtml);
|
|
234
166
|
}
|
|
235
167
|
|
|
236
168
|
// 3. Find and render the layout, injecting the page content
|
|
237
|
-
const layoutPath = await findTemplatePath(layoutFile);
|
|
169
|
+
const layoutPath = await findTemplatePath(layoutFile, templateSearchDirs);
|
|
238
170
|
if (!layoutPath) {
|
|
239
171
|
throw new Error(
|
|
240
|
-
`Layout "${layoutFile}" not found in [${
|
|
172
|
+
`Layout "${layoutFile}" not found in [${templateSearchDirs.join(", ")}]`,
|
|
241
173
|
);
|
|
242
174
|
}
|
|
243
175
|
|
|
244
176
|
const layoutTemplate = await getTemplate(layoutPath);
|
|
245
177
|
const layoutData = { ...data, ...data.layoutData, body: pageHtml };
|
|
246
|
-
const finalHtml = layoutTemplate(layoutData, sqrlConfig
|
|
178
|
+
const finalHtml = await layoutTemplate(layoutData, sqrlConfig);
|
|
247
179
|
|
|
248
180
|
return this.type("text/html").send(finalHtml);
|
|
249
181
|
} catch (error) {
|
|
250
|
-
|
|
182
|
+
this.request.server.log.error(error);
|
|
251
183
|
if (process.env.NODE_ENV === "production") {
|
|
252
184
|
// In production, send a generic error and don't leak details
|
|
253
185
|
this.status(500).send("An internal server error occurred.");
|
|
@@ -264,6 +196,13 @@ async function squirrellyify(fastify, options = {}) {
|
|
|
264
196
|
// Decorate the fastify instance so users can override settings in different scopes
|
|
265
197
|
fastify.decorate("views", null);
|
|
266
198
|
fastify.decorate("layout", null);
|
|
199
|
+
fastify.decorate("viewHelpers", viewHelpers);
|
|
200
|
+
fastify.decorate("viewFilters", viewFilters);
|
|
201
|
+
fastify.decorate("viewPartials", viewPartials);
|
|
202
|
+
fastify.decorate("viewCache", {
|
|
203
|
+
clear: clearCaches,
|
|
204
|
+
stats: cacheStats,
|
|
205
|
+
});
|
|
267
206
|
|
|
268
207
|
// Also expose the Squirrelly engine itself for advanced configuration (e.g., adding helpers/filters)
|
|
269
208
|
fastify.decorate("Sqrl", Sqrl);
|
package/src/resolver.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import Sqrl from "squirrelly";
|
|
5
|
+
|
|
6
|
+
function trimSlashes(value) {
|
|
7
|
+
return value.replace(/^\/+|\/+$/g, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function resolvePartialsNamespace(partialsNamespace, partialsDir) {
|
|
11
|
+
if (!partialsNamespace) {
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
if (partialsNamespace === true) {
|
|
15
|
+
return trimSlashes(path.basename(path.resolve(partialsDir)));
|
|
16
|
+
}
|
|
17
|
+
if (typeof partialsNamespace === "string") {
|
|
18
|
+
return trimSlashes(partialsNamespace.split("\\").join("/"));
|
|
19
|
+
}
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function collectPartialFiles(dir, extensionWithDot, recursive) {
|
|
24
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
25
|
+
const files = [];
|
|
26
|
+
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const fullPath = path.join(dir, entry.name);
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
if (recursive) {
|
|
31
|
+
files.push(...(await collectPartialFiles(fullPath, extensionWithDot, recursive)));
|
|
32
|
+
}
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (entry.isFile() && entry.name.endsWith(extensionWithDot)) {
|
|
36
|
+
files.push(fullPath);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return files;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolvePartialName(partialPath, partialsDir, extensionWithDot, namespace) {
|
|
44
|
+
const relativePath = path.relative(partialsDir, partialPath);
|
|
45
|
+
const withoutExt = relativePath.slice(0, -extensionWithDot.length);
|
|
46
|
+
const normalizedName = withoutExt.split(path.sep).join("/");
|
|
47
|
+
return namespace ? `${namespace}/${normalizedName}` : normalizedName;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Preload partial templates and define them in the configured Sqrl template store.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} options
|
|
54
|
+
* @param {string[]} options.partialsDirs
|
|
55
|
+
* @param {string} options.extensionWithDot
|
|
56
|
+
* @param {boolean} [options.partialsRecursive=true]
|
|
57
|
+
* @param {boolean|string} [options.partialsNamespace=false]
|
|
58
|
+
* @param {object} options.fastify
|
|
59
|
+
* @param {Function} options.defineSqrlTemplate
|
|
60
|
+
* @param {object} options.sqrlConfig
|
|
61
|
+
* @returns {Promise<void>}
|
|
62
|
+
*/
|
|
63
|
+
export async function preloadPartials({
|
|
64
|
+
partialsDirs,
|
|
65
|
+
extensionWithDot,
|
|
66
|
+
partialsRecursive = true,
|
|
67
|
+
partialsNamespace = false,
|
|
68
|
+
fastify,
|
|
69
|
+
defineSqrlTemplate,
|
|
70
|
+
sqrlConfig,
|
|
71
|
+
}) {
|
|
72
|
+
if (partialsDirs.length === 0) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const partialsDir of partialsDirs) {
|
|
77
|
+
try {
|
|
78
|
+
const namespace = resolvePartialsNamespace(partialsNamespace, partialsDir);
|
|
79
|
+
const files = await collectPartialFiles(partialsDir, extensionWithDot, partialsRecursive);
|
|
80
|
+
await Promise.all(
|
|
81
|
+
files.map(async (partialPath) => {
|
|
82
|
+
const partialName = resolvePartialName(
|
|
83
|
+
partialPath,
|
|
84
|
+
partialsDir,
|
|
85
|
+
extensionWithDot,
|
|
86
|
+
namespace,
|
|
87
|
+
);
|
|
88
|
+
const content = await fs.readFile(partialPath, "utf-8");
|
|
89
|
+
fastify.log.trace(`Loaded partial: ${partialName}`);
|
|
90
|
+
defineSqrlTemplate(partialName, Sqrl.compile(content, sqrlConfig));
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
fastify.log.error(`Error loading partials from ${partialsDir}: ${error.message}`);
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Collect encapsulated view dirs and layout overrides from current Fastify scope chain.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} instance
|
|
104
|
+
* @returns {{ aggregatedTemplatesDirs: string[], scopedLayout: string|null }}
|
|
105
|
+
*/
|
|
106
|
+
export function collectViewScope(instance) {
|
|
107
|
+
const aggregatedTemplatesDirs = [];
|
|
108
|
+
let scopedLayout = null;
|
|
109
|
+
let currentInstance = instance;
|
|
110
|
+
|
|
111
|
+
while (currentInstance) {
|
|
112
|
+
if (currentInstance.views) {
|
|
113
|
+
const dirs = Array.isArray(currentInstance.views)
|
|
114
|
+
? currentInstance.views
|
|
115
|
+
: [currentInstance.views];
|
|
116
|
+
aggregatedTemplatesDirs.push(...dirs);
|
|
117
|
+
}
|
|
118
|
+
if (scopedLayout === null && currentInstance.layout !== null && currentInstance.layout !== undefined) {
|
|
119
|
+
scopedLayout = currentInstance.layout;
|
|
120
|
+
}
|
|
121
|
+
currentInstance = currentInstance.parent ?? null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
aggregatedTemplatesDirs,
|
|
126
|
+
scopedLayout,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Merge initial template dirs with encapsulated scoped dirs.
|
|
132
|
+
*
|
|
133
|
+
* @param {string[]} scopedDirs
|
|
134
|
+
* @param {string[]} initialDirs
|
|
135
|
+
* @returns {string[]}
|
|
136
|
+
*/
|
|
137
|
+
export function buildTemplateSearchDirs(scopedDirs, initialDirs) {
|
|
138
|
+
return [...new Set([...scopedDirs, ...initialDirs])];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Build cached template loader / path resolver.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} options
|
|
145
|
+
* @param {object} options.fastify
|
|
146
|
+
* @param {string} options.extensionWithDot
|
|
147
|
+
* @param {boolean} options.useCache
|
|
148
|
+
* @param {object} options.sqrlConfig
|
|
149
|
+
* @returns {object}
|
|
150
|
+
*/
|
|
151
|
+
export function createTemplateResolver({ fastify, extensionWithDot, useCache, sqrlConfig }) {
|
|
152
|
+
const templateCache = new Map();
|
|
153
|
+
const pathCache = new Map();
|
|
154
|
+
const templateMeta = new Map();
|
|
155
|
+
|
|
156
|
+
async function findTemplatePath(templateName, searchDirs) {
|
|
157
|
+
const templateFile = `${templateName}${extensionWithDot}`;
|
|
158
|
+
const cacheKey = `${searchDirs.join(";")}:${templateFile}`;
|
|
159
|
+
|
|
160
|
+
if (useCache && pathCache.has(cacheKey)) {
|
|
161
|
+
return pathCache.get(cacheKey);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const dir of searchDirs) {
|
|
165
|
+
const fullPath = path.join(dir, templateFile);
|
|
166
|
+
try {
|
|
167
|
+
await fs.access(fullPath);
|
|
168
|
+
if (useCache) {
|
|
169
|
+
pathCache.set(cacheKey, fullPath);
|
|
170
|
+
}
|
|
171
|
+
return fullPath;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
fastify.log.trace(error);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function getTemplate(templatePath) {
|
|
181
|
+
if (useCache && templateCache.has(templatePath)) {
|
|
182
|
+
return templateCache.get(templatePath);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const content = await fs.readFile(templatePath, "utf-8");
|
|
186
|
+
const compiled = Sqrl.compile(content, sqrlConfig);
|
|
187
|
+
const hasLayoutTag = /{{\s*(?:@extends|!layout)\s*\(/.test(content);
|
|
188
|
+
templateMeta.set(templatePath, { hasLayoutTag });
|
|
189
|
+
|
|
190
|
+
if (useCache) {
|
|
191
|
+
templateCache.set(templatePath, compiled);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return compiled;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function hasLayoutTag(templatePath) {
|
|
198
|
+
return templateMeta.get(templatePath)?.hasLayoutTag === true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function clearCaches() {
|
|
202
|
+
templateCache.clear();
|
|
203
|
+
pathCache.clear();
|
|
204
|
+
templateMeta.clear();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function cacheStats() {
|
|
208
|
+
return {
|
|
209
|
+
enabled: useCache,
|
|
210
|
+
templates: templateCache.size,
|
|
211
|
+
paths: pathCache.size,
|
|
212
|
+
metadata: templateMeta.size,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
findTemplatePath,
|
|
218
|
+
getTemplate,
|
|
219
|
+
hasLayoutTag,
|
|
220
|
+
clearCaches,
|
|
221
|
+
cacheStats,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import Sqrl from "squirrelly";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build helper/filter/template runtime API wrappers around the selected
|
|
5
|
+
* Squirrelly storage scope (global or scoped).
|
|
6
|
+
*
|
|
7
|
+
* @param {object} options
|
|
8
|
+
* @param {"global"|"scoped"} options.sqrlScope
|
|
9
|
+
* @param {object} options.sqrlConfig
|
|
10
|
+
* @returns {object}
|
|
11
|
+
*/
|
|
12
|
+
export function createRuntimeApi({ sqrlScope, sqrlConfig }) {
|
|
13
|
+
const helpersStore = sqrlScope === "scoped" ? sqrlConfig.storage.helpers : Sqrl.helpers;
|
|
14
|
+
const filtersStore = sqrlScope === "scoped" ? sqrlConfig.storage.filters : Sqrl.filters;
|
|
15
|
+
const templatesStore = sqrlScope === "scoped" ? sqrlConfig.storage.templates : Sqrl.templates;
|
|
16
|
+
|
|
17
|
+
function defineSqrlHelper(name, fn) {
|
|
18
|
+
helpersStore.define(name, fn);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getSqrlHelper(name) {
|
|
22
|
+
return helpersStore.get(name);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function removeSqrlHelper(name) {
|
|
26
|
+
helpersStore.remove(name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function defineSqrlFilter(name, fn) {
|
|
30
|
+
filtersStore.define(name, fn);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getSqrlFilter(name) {
|
|
34
|
+
return filtersStore.get(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function removeSqrlFilter(name) {
|
|
38
|
+
filtersStore.remove(name);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function defineSqrlTemplate(name, template) {
|
|
42
|
+
const compiled = typeof template === "function" ? template : Sqrl.compile(String(template), sqrlConfig);
|
|
43
|
+
templatesStore.define(name, compiled);
|
|
44
|
+
return compiled;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getSqrlTemplate(name) {
|
|
48
|
+
return templatesStore.get(name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function removeSqrlTemplate(name) {
|
|
52
|
+
templatesStore.remove(name);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
defineSqrlHelper,
|
|
57
|
+
defineSqrlFilter,
|
|
58
|
+
defineSqrlTemplate,
|
|
59
|
+
viewHelpers: {
|
|
60
|
+
define: defineSqrlHelper,
|
|
61
|
+
get: getSqrlHelper,
|
|
62
|
+
remove: removeSqrlHelper,
|
|
63
|
+
},
|
|
64
|
+
viewFilters: {
|
|
65
|
+
define: defineSqrlFilter,
|
|
66
|
+
get: getSqrlFilter,
|
|
67
|
+
remove: removeSqrlFilter,
|
|
68
|
+
},
|
|
69
|
+
viewPartials: {
|
|
70
|
+
define: defineSqrlTemplate,
|
|
71
|
+
get: getSqrlTemplate,
|
|
72
|
+
remove: removeSqrlTemplate,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
package/src/safety.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Allow nested forward-slash paths (e.g. "admin/dashboard"), but block traversal
|
|
5
|
+
* or absolute paths.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} name
|
|
8
|
+
* @return {void}
|
|
9
|
+
*/
|
|
10
|
+
export function assertSafeName(name) {
|
|
11
|
+
if (typeof name !== "string" || name.length === 0 || name.includes("\0")) {
|
|
12
|
+
throw new Error(`Illegal template name: ${name}`);
|
|
13
|
+
}
|
|
14
|
+
if (name.includes("\\") || path.posix.isAbsolute(name) || path.win32.isAbsolute(name)) {
|
|
15
|
+
throw new Error(`Illegal template name: ${name}`);
|
|
16
|
+
}
|
|
17
|
+
const normalized = path.posix.normalize(name);
|
|
18
|
+
if (normalized !== name || normalized === "." || normalized === "..") {
|
|
19
|
+
throw new Error(`Illegal template name: ${name}`);
|
|
20
|
+
}
|
|
21
|
+
if (normalized.startsWith("../")) {
|
|
22
|
+
throw new Error(`Illegal template name: ${name}`);
|
|
23
|
+
}
|
|
24
|
+
if (name.split("/").some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
|
25
|
+
throw new Error(`Illegal template name: ${name}`);
|
|
26
|
+
}
|
|
27
|
+
}
|