@ynode/squirrellyify 1.1.1 → 1.3.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 CHANGED
@@ -74,6 +74,28 @@ fastify.listen({ port: 3000 }, (err) => {
74
74
  });
75
75
  ```
76
76
 
77
+ ### Request-Scoped View Data
78
+
79
+ `reply.view(template, data)` automatically merges request-scoped values from `reply.locals` and
80
+ `reply.context` into the template data:
81
+
82
+ ```javascript
83
+ fastify.addHook("preHandler", async (request, reply) => {
84
+ reply.locals = { appName: "YNode", greeting: "Welcome" };
85
+ });
86
+
87
+ fastify.get("/", (request, reply) => {
88
+ // Route-level values win over locals/context on key conflicts.
89
+ return reply.view("index", { greeting: "Hello" });
90
+ });
91
+ ```
92
+
93
+ Merge precedence is:
94
+
95
+ 1. `reply.context`
96
+ 2. `reply.locals`
97
+ 3. `reply.view(..., data)` (highest precedence)
98
+
77
99
  ## Configuration Options
78
100
 
79
101
  You can pass an options object when registering the plugin.
@@ -81,9 +103,11 @@ You can pass an options object when registering the plugin.
81
103
  | Option | Type | Default | Description |
82
104
  | ------------------ | -------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
83
105
  | `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 available by filename. |
106
+ | `partials` | `string \| string[]` | `[]` | The directory or directories for partial templates. All partials are loaded on startup and available by name. |
107
+ | `partialsRecursive` | `boolean` | `true` | If `true`, partials are loaded recursively from subdirectories. Names use forward slashes (for example, `emails/header`). |
108
+ | `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
109
  | `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. |
110
+ | `defaultExtension` | `string` | `"sqrl"` | The file extension for all template files. Leading `.` is optional (for example, `"html"` or `".html"`). |
87
111
  | `cache` | `boolean` | `NODE_ENV === "production"` | If `true`, compiled templates and resolved file paths will be cached in memory. |
88
112
  | `sqrl` | `object` | `undefined` | Squirrelly options. Supports `{ scope: "global" \| "scoped", config, helpers, filters }`. |
89
113
 
@@ -91,12 +115,19 @@ Runtime API after registration:
91
115
 
92
116
  - `fastify.viewHelpers.define(name, fn)`, `fastify.viewHelpers.get(name)`, `fastify.viewHelpers.remove(name)`
93
117
  - `fastify.viewFilters.define(name, fn)`, `fastify.viewFilters.get(name)`, `fastify.viewFilters.remove(name)`
118
+ - `fastify.viewPartials.define(name, templateOrFn)`, `fastify.viewPartials.get(name)`, `fastify.viewPartials.remove(name)`
119
+ - `fastify.viewCache.clear()`, `fastify.viewCache.stats()`
94
120
 
95
121
  These APIs are scope-aware:
96
122
 
97
- - In `global` mode they modify shared helpers/filters.
123
+ - In `global` mode they modify shared helpers/filters/partials.
98
124
  - In `scoped` mode they only affect the current plugin registration scope.
99
125
 
126
+ The cache API is process-local and lets you invalidate compiled template/path caches at runtime when
127
+ `cache: true` is used.
128
+
129
+ Invalid option types are rejected at plugin registration time with descriptive errors.
130
+
100
131
  ## Advanced Usage
101
132
 
102
133
  ### Layouts
@@ -161,7 +192,8 @@ You can specify a layout in three ways (in order of precedence):
161
192
  ### Partials
162
193
 
163
194
  Partials are reusable chunks of template code. Create a `partials` directory and place your files
164
- there. They will be automatically registered by their filename.
195
+ there. By default, partials are loaded recursively and registered by forward-slash path from the
196
+ partials directory root.
165
197
 
166
198
  **`partials/user-card.sqrl`**
167
199
 
@@ -176,7 +208,7 @@ there. They will be automatically registered by their filename.
176
208
 
177
209
  ```html
178
210
  <h1>Users</h1>
179
- {{ include('user-card', { name: 'John Doe', email: 'john@example.com' }) /}}
211
+ {{@include('user-card', { name: 'John Doe', email: 'john@example.com' })/}}
180
212
  ```
181
213
 
182
214
  **Register the `partials` directory:**
@@ -188,6 +220,42 @@ fastify.register(squirrellyify, {
188
220
  });
189
221
  ```
190
222
 
223
+ Nested partials use forward-slash names:
224
+
225
+ ```text
226
+ partials/
227
+ └── cards/
228
+ └── user-card.sqrl
229
+ ```
230
+
231
+ ```html
232
+ {{@include('cards/user-card', { name: 'John Doe', email: 'john@example.com' })/}}
233
+ ```
234
+
235
+ To disable recursive loading:
236
+
237
+ ```javascript
238
+ fastify.register(squirrellyify, {
239
+ templates: "views",
240
+ partials: "partials",
241
+ partialsRecursive: false,
242
+ });
243
+ ```
244
+
245
+ To namespace partial names:
246
+
247
+ ```javascript
248
+ fastify.register(squirrellyify, {
249
+ templates: "views",
250
+ partials: "partials",
251
+ partialsNamespace: "shared",
252
+ });
253
+ ```
254
+
255
+ ```html
256
+ {{@include('shared/cards/user-card', { name: 'John Doe', email: 'john@example.com' })/}}
257
+ ```
258
+
191
259
  ### Scoped Configuration (Encapsulation)
192
260
 
193
261
  This plugin supports Fastify's encapsulation model. You can register it multiple times with
@@ -197,8 +265,10 @@ different settings for different route prefixes.
197
265
  import Fastify from "fastify";
198
266
  import squirrellyify from "@ynode/squirrellyify";
199
267
  import path from "node:path";
268
+ import { fileURLToPath } from "node:url";
200
269
 
201
270
  const fastify = Fastify();
271
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
202
272
 
203
273
  // Register with default settings
204
274
  fastify.register(squirrellyify, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynode/squirrellyify",
3
- "version": "1.1.1+93166.v1.1-1-g7a9f0f7",
3
+ "version": "1.3.0",
4
4
  "description": "Fastify plugin for rendering Squirrelly templates.",
5
5
  "main": "src/plugin.js",
6
6
  "type": "module",
@@ -46,7 +46,7 @@
46
46
  "lint": "eslint .",
47
47
  "lint:fix": "eslint . --fix",
48
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' ' ')",
49
+ "test:staged": "node scripts/lint-staged.mjs",
50
50
  "ver:preview": "npx autover --no-amend --dry-run --short",
51
51
  "ver:apply": "npx autover --guard-unchanged --short",
52
52
  "test": "npm run lint && npm run test:integration",
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,12 +27,26 @@ 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
51
  * @typedef {object} FastifyInstance
38
52
  * @typedef {object} FastifyReply
@@ -47,6 +61,8 @@ 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".
@@ -57,191 +73,54 @@ import Sqrl from "squirrelly";
57
73
  * @param {Record<string, Function>} [options.sqrl.filters] Custom Squirrelly filters.
58
74
  */
59
75
  async function squirrellyify(fastify, options = {}) {
60
- // Get initial options and set defaults from the plugin registration
61
- const initialTemplatesDirs = Array.isArray(options.templates)
62
- ? options.templates
63
- : typeof options.templates === "string"
64
- ? [options.templates]
65
- : [path.join(process.cwd(), "views")];
66
-
67
- const initialPartialsDirs = Array.isArray(options.partials)
68
- ? options.partials
69
- : typeof options.partials === "string"
70
- ? [options.partials]
71
- : [];
76
+ validatePluginOptions(options);
72
77
 
78
+ const initialTemplatesDirs = resolveInitialTemplateDirs(options);
79
+ const initialPartialsDirs = resolveInitialPartialsDirs(options);
73
80
  const initialLayout = options.layout;
74
- const defaultExtension = options.defaultExtension || "sqrl";
75
- const extensionWithDot = `.${defaultExtension}`;
76
- const useCache = options.cache ?? process.env.NODE_ENV === "production";
77
- const templateCache = new Map();
78
- const pathCache = new Map();
79
- const templateMeta = new Map();
80
-
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
- );
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
+ });
130
101
  }
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);
102
+ if (options.sqrl?.filters) {
103
+ Object.entries(options.sqrl.filters).forEach(([name, fn]) => {
104
+ defineSqrlFilter(name, fn);
105
+ });
143
106
  }
144
107
 
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
- }
168
-
169
- // Allow Passing Custom Squirrelly Configuration
170
- if (options.sqrl) {
171
- if (options.sqrl.helpers) {
172
- Object.entries(options.sqrl.helpers).forEach(([name, fn]) => {
173
- defineSqrlHelper(name, fn);
174
- });
175
- }
176
- if (options.sqrl.filters) {
177
- Object.entries(options.sqrl.filters).forEach(([name, fn]) => {
178
- defineSqrlFilter(name, fn);
179
- });
180
- }
181
- }
182
-
183
- // Pre-load and define all partials on startup from all partial directories
184
- if (initialPartialsDirs.length > 0) {
185
- for (const partialsDir of initialPartialsDirs) {
186
- try {
187
- const files = await fs.readdir(partialsDir);
188
- await Promise.all(
189
- files.map(async (file) => {
190
- if (file.endsWith(extensionWithDot)) {
191
- const partialPath = path.join(partialsDir, file);
192
- const partialName = path.basename(file, extensionWithDot);
193
- const content = await fs.readFile(partialPath, "utf-8");
194
- fastify.log.trace(`Loaded partial: ${partialName}`);
195
- defineSqrlTemplate(partialName, Sqrl.compile(content, sqrlConfig));
196
- }
197
- }),
198
- );
199
- } catch (error) {
200
- fastify.log.error(`Error loading partials from ${partialsDir}: ${error.message}`);
201
- throw error;
202
- }
203
- }
204
- }
205
-
206
- /**
207
- * Compiles a template from a file path and caches it if enabled.
208
- */
209
- async function getTemplate(templatePath) {
210
- if (useCache && templateCache.has(templatePath)) {
211
- return templateCache.get(templatePath);
212
- }
213
- const content = await fs.readFile(templatePath, "utf-8");
214
- const hasLayoutTag = /{{\s*(?:@extends|!layout)\s*\(/.test(content);
215
- const compiled = Sqrl.compile(content, sqrlConfig);
216
- templateMeta.set(templatePath, { hasLayoutTag });
217
- if (useCache) {
218
- templateCache.set(templatePath, compiled);
219
- }
220
- return compiled;
221
- }
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
+ });
222
117
 
223
- /**
224
- * Allow nested forward-slash paths (e.g. "admin/dashboard"), but block traversal
225
- * or absolute paths.
226
- */
227
- function assertSafeName(name) {
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 === "..")) {
242
- throw new Error(`Illegal template name: ${name}`);
243
- }
244
- }
118
+ const { findTemplatePath, getTemplate, hasLayoutTag, clearCaches, cacheStats } = createTemplateResolver({
119
+ fastify,
120
+ extensionWithDot,
121
+ useCache,
122
+ sqrlConfig,
123
+ });
245
124
 
246
125
  /**
247
126
  * Renders a Squirrelly template and sends it as an HTML response.
@@ -251,96 +130,66 @@ async function squirrellyify(fastify, options = {}) {
251
130
  */
252
131
  async function view(template, data = {}) {
253
132
  try {
133
+ const requestData = data && typeof data === "object" ? data : {};
134
+ const replyContext =
135
+ this.context && typeof this.context === "object" ? this.context : {};
136
+ const replyLocals =
137
+ this.locals && typeof this.locals === "object" ? this.locals : {};
138
+ const mergedData = {
139
+ ...replyContext,
140
+ ...replyLocals,
141
+ ...requestData,
142
+ };
143
+
254
144
  assertSafeName(template);
255
- if (data.layout && data.layout !== false) {
256
- assertSafeName(data.layout);
145
+ if (mergedData.layout && mergedData.layout !== false) {
146
+ assertSafeName(mergedData.layout);
257
147
  }
258
148
 
259
149
  const instance = this.request.server;
260
-
261
- const aggregatedTemplatesDirs = [];
262
- let scopedLayout = null;
263
- let currentInstance = instance;
264
- while (currentInstance) {
265
- if (currentInstance.views) {
266
- const dirs = Array.isArray(currentInstance.views)
267
- ? currentInstance.views
268
- : [currentInstance.views];
269
- aggregatedTemplatesDirs.push(...dirs);
270
- }
271
- if (
272
- scopedLayout === null &&
273
- currentInstance.layout !== null &&
274
- currentInstance.layout !== undefined
275
- ) {
276
- scopedLayout = currentInstance.layout;
277
- }
278
- // Defensive: parent may be undefined or private
279
- currentInstance = currentInstance.parent ?? null;
280
- }
281
-
282
- const combinedDirs = [
283
- ...new Set([...aggregatedTemplatesDirs, ...initialTemplatesDirs]),
284
- ];
285
- const allSearchDirs = [...new Set([...combinedDirs, ...initialPartialsDirs])];
286
-
287
- async function findTemplatePath(templateName) {
288
- const templateFile = `${templateName}${extensionWithDot}`;
289
- const cacheKey = `${allSearchDirs.join(";")}:${templateFile}`; // Create a unique key
290
-
291
- if (useCache && pathCache.has(cacheKey)) {
292
- return pathCache.get(cacheKey);
293
- }
294
-
295
- for (const dir of allSearchDirs) {
296
- const fullPath = path.join(dir, templateFile);
297
- try {
298
- await fs.access(fullPath);
299
- if (useCache) {
300
- pathCache.set(cacheKey, fullPath); // Cache the found path
301
- }
302
- return fullPath;
303
- } catch (error) {
304
- fastify.log.trace(error);
305
- }
306
- }
307
- return null;
308
- }
150
+ const { aggregatedTemplatesDirs, scopedLayout } = collectViewScope(instance);
151
+ const templateSearchDirs = buildTemplateSearchDirs(
152
+ aggregatedTemplatesDirs,
153
+ initialTemplatesDirs,
154
+ );
309
155
 
310
156
  // 1. Find and render the page template
311
- const pagePath = await findTemplatePath(template);
157
+ const pagePath = await findTemplatePath(template, templateSearchDirs);
312
158
  if (!pagePath) {
313
159
  throw new Error(
314
- `Template "${template}" not found in [${allSearchDirs.join(", ")}]`,
160
+ `Template "${template}" not found in [${templateSearchDirs.join(", ")}]`,
315
161
  );
316
162
  }
317
163
 
318
164
  const pageTemplate = await getTemplate(pagePath);
319
- const pageHtml = await pageTemplate(data, sqrlConfig);
165
+ const pageHtml = await pageTemplate(mergedData, sqrlConfig);
320
166
 
321
167
  // 2. Determine which layout to use
322
168
  const currentLayout = scopedLayout !== null ? scopedLayout : initialLayout;
323
- const layoutFile = data.layout === false ? null : data.layout || currentLayout;
169
+ const layoutFile = mergedData.layout === false ? null : mergedData.layout || currentLayout;
324
170
 
325
171
  if (!layoutFile) {
326
172
  return this.type("text/html").send(pageHtml);
327
173
  }
328
174
 
329
- const hasLayoutTag = templateMeta.get(pagePath)?.hasLayoutTag === true;
330
- if (hasLayoutTag) {
175
+ if (hasLayoutTag(pagePath)) {
331
176
  return this.type("text/html").send(pageHtml);
332
177
  }
333
178
 
334
179
  // 3. Find and render the layout, injecting the page content
335
- const layoutPath = await findTemplatePath(layoutFile);
180
+ const layoutPath = await findTemplatePath(layoutFile, templateSearchDirs);
336
181
  if (!layoutPath) {
337
182
  throw new Error(
338
- `Layout "${layoutFile}" not found in [${allSearchDirs.join(", ")}]`,
183
+ `Layout "${layoutFile}" not found in [${templateSearchDirs.join(", ")}]`,
339
184
  );
340
185
  }
341
186
 
342
187
  const layoutTemplate = await getTemplate(layoutPath);
343
- const layoutData = { ...data, ...data.layoutData, body: pageHtml };
188
+ const layoutPayload =
189
+ mergedData.layoutData && typeof mergedData.layoutData === "object"
190
+ ? mergedData.layoutData
191
+ : {};
192
+ const layoutData = { ...mergedData, ...layoutPayload, body: pageHtml };
344
193
  const finalHtml = await layoutTemplate(layoutData, sqrlConfig);
345
194
 
346
195
  return this.type("text/html").send(finalHtml);
@@ -362,15 +211,12 @@ async function squirrellyify(fastify, options = {}) {
362
211
  // Decorate the fastify instance so users can override settings in different scopes
363
212
  fastify.decorate("views", null);
364
213
  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,
214
+ fastify.decorate("viewHelpers", viewHelpers);
215
+ fastify.decorate("viewFilters", viewFilters);
216
+ fastify.decorate("viewPartials", viewPartials);
217
+ fastify.decorate("viewCache", {
218
+ clear: clearCaches,
219
+ stats: cacheStats,
374
220
  });
375
221
 
376
222
  // Also expose the Squirrelly engine itself for advanced configuration (e.g., adding helpers/filters)
@@ -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
+ }