@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.
Files changed (3) hide show
  1. package/README.md +24 -7
  2. package/package.json +12 -6
  3. 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
  [![npm version](https://img.shields.io/npm/v/@ynode/squirrellyify.svg)](https://www.npmjs.com/package/@ynode/squirrellyify)
6
- [![build](https://img.shields.io/github/actions/workflow/status/yammm/ynode-squirrellyify/your-workflow.yml)](https://github.com/yammm/ynode-squirrellyify/actions)
7
- [![license](https://img.shields.io/npm/l/@ynode/squirrellyify.svg)](https://github.com/yammm/ynode-squirrellyify/blob/main/LICENSE)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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:** Respects Fastify's encapsulation model for s ed configurations.
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 are available globally by their filename. |
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` | An object to configure Squirrelly. Currently supports `{ helpers: {}, filters: {} }` for adding custom functions. |
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 }}</h4>
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.0.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": "eslint --no-warn-ignored $(git diff --cached --name-only --diff-filter=ACMRTUXB | tr '\\n' ' ')",
50
- "prepublishOnly": "npm test || true",
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 {import("fastify").FastifyInstance} FastifyInstance
38
- * @typedef {import("fastify").FastifyReply} FastifyReply
39
- * @typedef {import("squirrelly").SqrlConfig} SqrlConfig
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
- ? [options.templates]
60
- : [path.join(process.cwd(), "views")];
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
- ? [options.partials]
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
- // Allow passing optional Squirrelly compile/render configuration
77
- const sqrlConfig = options.sqrl?.config;
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
- Sqrl.helpers.define(name, fn);
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
- Sqrl.filters.define(name, fn);
178
+ defineSqrlFilter(name, fn);
89
179
  });
90
180
  }
91
181
  }
92
182
 
93
- // Pre-load and define all partials globally on startup from all partial directories
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
- Sqrl.templates.define(partialName, Sqrl.compile(content, sqrlConfig));
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
- * Because template comes from route code, a mistaken ../ could escape the views dir.
135
- * Disallow path separators and .. in template/layout names.
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.includes("..") ||
140
- name.includes(path.sep) ||
141
- name.includes("/") ||
142
- name.includes("\\")
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 ?? Sqrl.defaultConfig);
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 ?? Sqrl.defaultConfig);
344
+ const finalHtml = await layoutTemplate(layoutData, sqrlConfig);
247
345
 
248
346
  return this.type("text/html").send(finalHtml);
249
347
  } catch (error) {
250
- fastify.log.error(error);
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
  });