@ynode/squirrellyify 1.0.1 → 1.1.1
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 +24 -7
- package/package.json +12 -6
- package/src/plugin.js +133 -25
package/README.md
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
Copyright (c) 2025 Michael Welter <me@mikinho.com>
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@ynode/squirrellyify)
|
|
6
|
-
[](https://github.com/yammm/ynode-squirrellyify/blob/main/LICENSE)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
8
7
|
|
|
9
8
|
A simple and fast plugin for using the [Squirrelly](https://squirrelly.js.org/) template engine with
|
|
10
9
|
[Fastify](https://www.fastify.io/).
|
|
@@ -14,7 +13,7 @@ A simple and fast plugin for using the [Squirrelly](https://squirrelly.js.org/)
|
|
|
14
13
|
- 🐿️ **Modern Templating:** Full support for Squirrelly v9 features.
|
|
15
14
|
- ⚡ **High Performance:** Template caching is enabled by default in production.
|
|
16
15
|
- 📁 **Layouts & Partials:** Built-in support for layouts and shared partials.
|
|
17
|
-
- 🧬 **Encapsulation-Aware:**
|
|
16
|
+
- 🧬 **Encapsulation-Aware:** Supports Fastify encapsulation with scoped template settings.
|
|
18
17
|
- 🛡️ **Secure:** Protects against path traversal attacks in template names.
|
|
19
18
|
- 🔧 **Extensible:** Easily add custom Squirrelly helpers and filters.
|
|
20
19
|
|
|
@@ -82,11 +81,21 @@ You can pass an options object when registering the plugin.
|
|
|
82
81
|
| Option | Type | Default | Description |
|
|
83
82
|
| ------------------ | -------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
84
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. |
|
|
85
|
-
| `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 filename. |
|
|
86
85
|
| `layout` | `string` | `undefined` | The name of the default layout file to use (without extension). Can be overridden per-route. |
|
|
87
86
|
| `defaultExtension` | `string` | `"sqrl"` | The file extension for all template files. |
|
|
88
87
|
| `cache` | `boolean` | `NODE_ENV === "production"` | If `true`, compiled templates and resolved file paths will be cached in memory. |
|
|
89
|
-
| `sqrl` | `object` | `undefined` |
|
|
88
|
+
| `sqrl` | `object` | `undefined` | Squirrelly options. Supports `{ scope: "global" \| "scoped", config, helpers, filters }`. |
|
|
89
|
+
|
|
90
|
+
Runtime API after registration:
|
|
91
|
+
|
|
92
|
+
- `fastify.viewHelpers.define(name, fn)`, `fastify.viewHelpers.get(name)`, `fastify.viewHelpers.remove(name)`
|
|
93
|
+
- `fastify.viewFilters.define(name, fn)`, `fastify.viewFilters.get(name)`, `fastify.viewFilters.remove(name)`
|
|
94
|
+
|
|
95
|
+
These APIs are scope-aware:
|
|
96
|
+
|
|
97
|
+
- In `global` mode they modify shared helpers/filters.
|
|
98
|
+
- In `scoped` mode they only affect the current plugin registration scope.
|
|
90
99
|
|
|
91
100
|
## Advanced Usage
|
|
92
101
|
|
|
@@ -158,7 +167,7 @@ there. They will be automatically registered by their filename.
|
|
|
158
167
|
|
|
159
168
|
```html
|
|
160
169
|
<div class="card">
|
|
161
|
-
<h3>{{ it.name }}</
|
|
170
|
+
<h3>{{ it.name }}</h3>
|
|
162
171
|
<p>{{ it.email }}</p>
|
|
163
172
|
</div>
|
|
164
173
|
```
|
|
@@ -224,6 +233,14 @@ fastify.register(
|
|
|
224
233
|
|
|
225
234
|
You can extend Squirrelly with custom helper and filter functions via the `sqrl` option.
|
|
226
235
|
|
|
236
|
+
Use `sqrl.scope` to choose registration mode:
|
|
237
|
+
|
|
238
|
+
- `global` (default): helpers, filters, and partials are shared across plugin registrations.
|
|
239
|
+
- `scoped`: helpers, filters, and partials are isolated to each plugin registration.
|
|
240
|
+
|
|
241
|
+
You can also add/remove helpers and filters at runtime via `fastify.viewHelpers` and
|
|
242
|
+
`fastify.viewFilters`.
|
|
243
|
+
|
|
227
244
|
```javascript
|
|
228
245
|
fastify.register(squirrellyify, {
|
|
229
246
|
templates: "views",
|
|
@@ -244,4 +261,4 @@ fastify.register(squirrellyify, {
|
|
|
244
261
|
|
|
245
262
|
## License
|
|
246
263
|
|
|
247
|
-
[MIT](./LICENSE)
|
|
264
|
+
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.1.1+93166.v1.1-1-g7a9f0f7",
|
|
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": "eslint --no-warn-ignored $(git diff --cached --name-only --diff-filter=ACMRTUXB | tr '\\n' ' ')",
|
|
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": {
|
|
@@ -59,7 +62,10 @@
|
|
|
59
62
|
"LICENSE"
|
|
60
63
|
],
|
|
61
64
|
"dependencies": {
|
|
62
|
-
"fastify-plugin": "^5.1.0"
|
|
65
|
+
"fastify-plugin": "^5.1.0"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"fastify": "^5.0.0",
|
|
63
69
|
"squirrelly": "^9.1.0"
|
|
64
70
|
}
|
|
65
71
|
}
|
package/src/plugin.js
CHANGED
|
@@ -34,9 +34,9 @@ import fp from "fastify-plugin";
|
|
|
34
34
|
import Sqrl from "squirrelly";
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
* @typedef {
|
|
38
|
-
* @typedef {
|
|
39
|
-
* @typedef {
|
|
37
|
+
* @typedef {object} FastifyInstance
|
|
38
|
+
* @typedef {object} FastifyReply
|
|
39
|
+
* @typedef {object} SqrlConfig
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
42
|
/**
|
|
@@ -50,20 +50,25 @@ import Sqrl from "squirrelly";
|
|
|
50
50
|
* @param {string} [options.layout] The name of the default layout file to use (without extension).
|
|
51
51
|
* @param {string} [options.defaultExtension="sqrl"] The default extension for template files.
|
|
52
52
|
* @param {boolean} [options.cache] Enables template caching. Defaults to true if NODE_ENV is "production".
|
|
53
|
+
* @param {object} [options.sqrl] Squirrelly engine options.
|
|
54
|
+
* @param {"global"|"scoped"} [options.sqrl.scope="global"] Whether to share helpers/filters/partials globally or isolate them per Fastify registration.
|
|
55
|
+
* @param {SqrlConfig} [options.sqrl.config] Squirrelly compile/render config.
|
|
56
|
+
* @param {Record<string, Function>} [options.sqrl.helpers] Custom Squirrelly helpers.
|
|
57
|
+
* @param {Record<string, Function>} [options.sqrl.filters] Custom Squirrelly filters.
|
|
53
58
|
*/
|
|
54
59
|
async function squirrellyify(fastify, options = {}) {
|
|
55
60
|
// Get initial options and set defaults from the plugin registration
|
|
56
61
|
const initialTemplatesDirs = Array.isArray(options.templates)
|
|
57
62
|
? options.templates
|
|
58
63
|
: typeof options.templates === "string"
|
|
59
|
-
|
|
60
|
-
|
|
64
|
+
? [options.templates]
|
|
65
|
+
: [path.join(process.cwd(), "views")];
|
|
61
66
|
|
|
62
67
|
const initialPartialsDirs = Array.isArray(options.partials)
|
|
63
68
|
? options.partials
|
|
64
69
|
: typeof options.partials === "string"
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
? [options.partials]
|
|
71
|
+
: [];
|
|
67
72
|
|
|
68
73
|
const initialLayout = options.layout;
|
|
69
74
|
const defaultExtension = options.defaultExtension || "sqrl";
|
|
@@ -73,24 +78,109 @@ async function squirrellyify(fastify, options = {}) {
|
|
|
73
78
|
const pathCache = new Map();
|
|
74
79
|
const templateMeta = new Map();
|
|
75
80
|
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
/**
|
|
82
|
+
* When using scoped mode, clone built-ins into per-plugin caches so helpers/filters/partials
|
|
83
|
+
* do not bleed across Fastify encapsulation boundaries.
|
|
84
|
+
*
|
|
85
|
+
* @param {SqrlConfig|undefined} baseConfig
|
|
86
|
+
* @returns {SqrlConfig}
|
|
87
|
+
*/
|
|
88
|
+
function createScopedSqrlConfig(baseConfig) {
|
|
89
|
+
const Cacher = Sqrl.helpers?.constructor;
|
|
90
|
+
if (typeof Cacher !== "function") {
|
|
91
|
+
throw new Error("Unable to initialize scoped Squirrelly storage.");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const scopedHelpers = new Cacher({});
|
|
95
|
+
const scopedFilters = new Cacher({});
|
|
96
|
+
const scopedTemplates = new Cacher({});
|
|
97
|
+
|
|
98
|
+
for (const helperName of [
|
|
99
|
+
"each",
|
|
100
|
+
"foreach",
|
|
101
|
+
"include",
|
|
102
|
+
"extends",
|
|
103
|
+
"useScope",
|
|
104
|
+
"includeFile",
|
|
105
|
+
"extendsFile",
|
|
106
|
+
]) {
|
|
107
|
+
const helperFn = Sqrl.helpers.get(helperName);
|
|
108
|
+
if (helperFn) {
|
|
109
|
+
scopedHelpers.define(helperName, helperFn);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const escapeFilter = Sqrl.filters.get("e");
|
|
114
|
+
if (escapeFilter) {
|
|
115
|
+
scopedFilters.define("e", escapeFilter);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return Sqrl.getConfig(
|
|
119
|
+
{
|
|
120
|
+
...baseConfig,
|
|
121
|
+
storage: {
|
|
122
|
+
helpers: scopedHelpers,
|
|
123
|
+
nativeHelpers: Sqrl.nativeHelpers,
|
|
124
|
+
filters: scopedFilters,
|
|
125
|
+
templates: scopedTemplates,
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
Sqrl.defaultConfig,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const sqrlScope = options.sqrl?.scope === "scoped" ? "scoped" : "global";
|
|
133
|
+
const sqrlConfig =
|
|
134
|
+
sqrlScope === "scoped"
|
|
135
|
+
? createScopedSqrlConfig(options.sqrl?.config)
|
|
136
|
+
: Sqrl.getConfig(options.sqrl?.config ?? {}, Sqrl.defaultConfig);
|
|
137
|
+
const helpersStore = sqrlScope === "scoped" ? sqrlConfig.storage.helpers : Sqrl.helpers;
|
|
138
|
+
const filtersStore = sqrlScope === "scoped" ? sqrlConfig.storage.filters : Sqrl.filters;
|
|
139
|
+
const templatesStore = sqrlScope === "scoped" ? sqrlConfig.storage.templates : Sqrl.templates;
|
|
140
|
+
|
|
141
|
+
function defineSqrlHelper(name, fn) {
|
|
142
|
+
helpersStore.define(name, fn);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getSqrlHelper(name) {
|
|
146
|
+
return helpersStore.get(name);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function removeSqrlHelper(name) {
|
|
150
|
+
helpersStore.remove(name);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function defineSqrlFilter(name, fn) {
|
|
154
|
+
filtersStore.define(name, fn);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function getSqrlFilter(name) {
|
|
158
|
+
return filtersStore.get(name);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function removeSqrlFilter(name) {
|
|
162
|
+
filtersStore.remove(name);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function defineSqrlTemplate(name, fn) {
|
|
166
|
+
templatesStore.define(name, fn);
|
|
167
|
+
}
|
|
78
168
|
|
|
79
169
|
// Allow Passing Custom Squirrelly Configuration
|
|
80
170
|
if (options.sqrl) {
|
|
81
171
|
if (options.sqrl.helpers) {
|
|
82
172
|
Object.entries(options.sqrl.helpers).forEach(([name, fn]) => {
|
|
83
|
-
|
|
173
|
+
defineSqrlHelper(name, fn);
|
|
84
174
|
});
|
|
85
175
|
}
|
|
86
176
|
if (options.sqrl.filters) {
|
|
87
177
|
Object.entries(options.sqrl.filters).forEach(([name, fn]) => {
|
|
88
|
-
|
|
178
|
+
defineSqrlFilter(name, fn);
|
|
89
179
|
});
|
|
90
180
|
}
|
|
91
181
|
}
|
|
92
182
|
|
|
93
|
-
// Pre-load and define all partials
|
|
183
|
+
// Pre-load and define all partials on startup from all partial directories
|
|
94
184
|
if (initialPartialsDirs.length > 0) {
|
|
95
185
|
for (const partialsDir of initialPartialsDirs) {
|
|
96
186
|
try {
|
|
@@ -102,7 +192,7 @@ async function squirrellyify(fastify, options = {}) {
|
|
|
102
192
|
const partialName = path.basename(file, extensionWithDot);
|
|
103
193
|
const content = await fs.readFile(partialPath, "utf-8");
|
|
104
194
|
fastify.log.trace(`Loaded partial: ${partialName}`);
|
|
105
|
-
|
|
195
|
+
defineSqrlTemplate(partialName, Sqrl.compile(content, sqrlConfig));
|
|
106
196
|
}
|
|
107
197
|
}),
|
|
108
198
|
);
|
|
@@ -131,16 +221,24 @@ async function squirrellyify(fastify, options = {}) {
|
|
|
131
221
|
}
|
|
132
222
|
|
|
133
223
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
224
|
+
* Allow nested forward-slash paths (e.g. "admin/dashboard"), but block traversal
|
|
225
|
+
* or absolute paths.
|
|
136
226
|
*/
|
|
137
227
|
function assertSafeName(name) {
|
|
138
|
-
if (
|
|
139
|
-
name
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
name
|
|
143
|
-
|
|
228
|
+
if (typeof name !== "string" || name.length === 0 || name.includes("\0")) {
|
|
229
|
+
throw new Error(`Illegal template name: ${name}`);
|
|
230
|
+
}
|
|
231
|
+
if (name.includes("\\") || path.posix.isAbsolute(name) || path.win32.isAbsolute(name)) {
|
|
232
|
+
throw new Error(`Illegal template name: ${name}`);
|
|
233
|
+
}
|
|
234
|
+
const normalized = path.posix.normalize(name);
|
|
235
|
+
if (normalized !== name || normalized === "." || normalized === "..") {
|
|
236
|
+
throw new Error(`Illegal template name: ${name}`);
|
|
237
|
+
}
|
|
238
|
+
if (normalized.startsWith("../")) {
|
|
239
|
+
throw new Error(`Illegal template name: ${name}`);
|
|
240
|
+
}
|
|
241
|
+
if (name.split("/").some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
|
144
242
|
throw new Error(`Illegal template name: ${name}`);
|
|
145
243
|
}
|
|
146
244
|
}
|
|
@@ -218,7 +316,7 @@ async function squirrellyify(fastify, options = {}) {
|
|
|
218
316
|
}
|
|
219
317
|
|
|
220
318
|
const pageTemplate = await getTemplate(pagePath);
|
|
221
|
-
const pageHtml = pageTemplate(data, sqrlConfig
|
|
319
|
+
const pageHtml = await pageTemplate(data, sqrlConfig);
|
|
222
320
|
|
|
223
321
|
// 2. Determine which layout to use
|
|
224
322
|
const currentLayout = scopedLayout !== null ? scopedLayout : initialLayout;
|
|
@@ -243,11 +341,11 @@ async function squirrellyify(fastify, options = {}) {
|
|
|
243
341
|
|
|
244
342
|
const layoutTemplate = await getTemplate(layoutPath);
|
|
245
343
|
const layoutData = { ...data, ...data.layoutData, body: pageHtml };
|
|
246
|
-
const finalHtml = layoutTemplate(layoutData, sqrlConfig
|
|
344
|
+
const finalHtml = await layoutTemplate(layoutData, sqrlConfig);
|
|
247
345
|
|
|
248
346
|
return this.type("text/html").send(finalHtml);
|
|
249
347
|
} catch (error) {
|
|
250
|
-
|
|
348
|
+
this.request.server.log.error(error);
|
|
251
349
|
if (process.env.NODE_ENV === "production") {
|
|
252
350
|
// In production, send a generic error and don't leak details
|
|
253
351
|
this.status(500).send("An internal server error occurred.");
|
|
@@ -264,6 +362,16 @@ async function squirrellyify(fastify, options = {}) {
|
|
|
264
362
|
// Decorate the fastify instance so users can override settings in different scopes
|
|
265
363
|
fastify.decorate("views", null);
|
|
266
364
|
fastify.decorate("layout", null);
|
|
365
|
+
fastify.decorate("viewHelpers", {
|
|
366
|
+
define: defineSqrlHelper,
|
|
367
|
+
get: getSqrlHelper,
|
|
368
|
+
remove: removeSqrlHelper,
|
|
369
|
+
});
|
|
370
|
+
fastify.decorate("viewFilters", {
|
|
371
|
+
define: defineSqrlFilter,
|
|
372
|
+
get: getSqrlFilter,
|
|
373
|
+
remove: removeSqrlFilter,
|
|
374
|
+
});
|
|
267
375
|
|
|
268
376
|
// Also expose the Squirrelly engine itself for advanced configuration (e.g., adding helpers/filters)
|
|
269
377
|
fastify.decorate("Sqrl", Sqrl);
|
|
@@ -271,5 +379,5 @@ async function squirrellyify(fastify, options = {}) {
|
|
|
271
379
|
|
|
272
380
|
export default fp(squirrellyify, {
|
|
273
381
|
fastify: "5.x",
|
|
274
|
-
name: "squirrellyify",
|
|
382
|
+
name: "@ynode/squirrellyify",
|
|
275
383
|
});
|