@ynode/squirrellyify 1.3.0 → 1.5.2

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
@@ -2,11 +2,9 @@
2
2
 
3
3
  Copyright (c) 2025 Michael Welter <me@mikinho.com>
4
4
 
5
- [![npm version](https://img.shields.io/npm/v/@ynode/squirrellyify.svg)](https://www.npmjs.com/package/@ynode/squirrellyify)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![npm version](https://img.shields.io/npm/v/@ynode/squirrellyify.svg)](https://www.npmjs.com/package/@ynode/squirrellyify) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
6
 
8
- A simple and fast plugin for using the [Squirrelly](https://squirrelly.js.org/) template engine with
9
- [Fastify](https://www.fastify.io/).
7
+ A simple and fast plugin for using the [Squirrelly](https://squirrelly.js.org/) template engine with [Fastify](https://www.fastify.io/).
10
8
 
11
9
  ## Features
12
10
 
@@ -76,8 +74,7 @@ fastify.listen({ port: 3000 }, (err) => {
76
74
 
77
75
  ### Request-Scoped View Data
78
76
 
79
- `reply.view(template, data)` automatically merges request-scoped values from `reply.locals` and
80
- `reply.context` into the template data:
77
+ `reply.view(template, data)` automatically merges request-scoped values from `reply.locals` and `reply.context` into the template data:
81
78
 
82
79
  ```javascript
83
80
  fastify.addHook("preHandler", async (request, reply) => {
@@ -100,16 +97,16 @@ Merge precedence is:
100
97
 
101
98
  You can pass an options object when registering the plugin.
102
99
 
103
- | Option | Type | Default | Description |
104
- | ------------------ | -------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
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. |
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. |
109
- | `layout` | `string` | `undefined` | The name of the default layout file to use (without extension). Can be overridden per-route. |
110
- | `defaultExtension` | `string` | `"sqrl"` | The file extension for all template files. Leading `.` is optional (for example, `"html"` or `".html"`). |
111
- | `cache` | `boolean` | `NODE_ENV === "production"` | If `true`, compiled templates and resolved file paths will be cached in memory. |
112
- | `sqrl` | `object` | `undefined` | Squirrelly options. Supports `{ scope: "global" \| "scoped", config, helpers, filters }`. |
100
+ | Option | Type | Default | Description |
101
+ | --- | --- | --- | --- |
102
+ | `templates` | `string \| string[]` | `path.join(process.cwd(), "views")` | The directory or directories to search for page and layout templates. Searched in the provided order. |
103
+ | `partials` | `string \| string[]` | `[]` | The directory or directories for partial templates. All partials are loaded on startup and available by name. |
104
+ | `partialsRecursive` | `boolean` | `true` | If `true`, partials are loaded recursively from subdirectories. Names use forward slashes (for example, `emails/header`). |
105
+ | `partialsNamespace` | `boolean \| string` | `false` | Optional namespace prefix for partial names. Use `true` to prefix with each partials directory basename, or provide a custom string. |
106
+ | `layout` | `string` | `undefined` | The name of the default layout file to use (without extension). Can be overridden per-route. |
107
+ | `defaultExtension` | `string` | `"sqrl"` | The file extension for all template files. Leading `.` is optional (for example, `"html"` or `".html"`). |
108
+ | `cache` | `boolean` | `NODE_ENV === "production"` | If `true`, compiled templates and resolved file paths will be cached in memory. |
109
+ | `sqrl` | `object` | `undefined` | Squirrelly options. Supports `{ scope: "global" \| "scoped", config, helpers, filters }`. |
113
110
 
114
111
  Runtime API after registration:
115
112
 
@@ -123,8 +120,7 @@ These APIs are scope-aware:
123
120
  - In `global` mode they modify shared helpers/filters/partials.
124
121
  - In `scoped` mode they only affect the current plugin registration scope.
125
122
 
126
- The cache API is process-local and lets you invalidate compiled template/path caches at runtime when
127
- `cache: true` is used.
123
+ The cache API is process-local and lets you invalidate compiled template/path caches at runtime when `cache: true` is used.
128
124
 
129
125
  Invalid option types are rejected at plugin registration time with descriptive errors.
130
126
 
@@ -132,8 +128,7 @@ Invalid option types are rejected at plugin registration time with descriptive e
132
128
 
133
129
  ### Layouts
134
130
 
135
- Layouts are wrappers for your page templates. The rendered page content is injected into the `body`
136
- variable within the layout.
131
+ Layouts are wrappers for your page templates. The rendered page content is injected into the `body` variable within the layout.
137
132
 
138
133
  **`views/layouts/main.sqrl`**
139
134
 
@@ -191,9 +186,7 @@ You can specify a layout in three ways (in order of precedence):
191
186
 
192
187
  ### Partials
193
188
 
194
- Partials are reusable chunks of template code. Create a `partials` directory and place your files
195
- there. By default, partials are loaded recursively and registered by forward-slash path from the
196
- partials directory root.
189
+ Partials are reusable chunks of template code. Create a `partials` directory and place your files there. By default, partials are loaded recursively and registered by forward-slash path from the partials directory root.
197
190
 
198
191
  **`partials/user-card.sqrl`**
199
192
 
@@ -258,8 +251,7 @@ fastify.register(squirrellyify, {
258
251
 
259
252
  ### Scoped Configuration (Encapsulation)
260
253
 
261
- This plugin supports Fastify's encapsulation model. You can register it multiple times with
262
- different settings for different route prefixes.
254
+ This plugin supports Fastify's encapsulation model. You can register it multiple times with different settings for different route prefixes.
263
255
 
264
256
  ```javascript
265
257
  import Fastify from "fastify";
@@ -308,8 +300,7 @@ Use `sqrl.scope` to choose registration mode:
308
300
  - `global` (default): helpers, filters, and partials are shared across plugin registrations.
309
301
  - `scoped`: helpers, filters, and partials are isolated to each plugin registration.
310
302
 
311
- You can also add/remove helpers and filters at runtime via `fastify.viewHelpers` and
312
- `fastify.viewFilters`.
303
+ You can also add/remove helpers and filters at runtime via `fastify.viewHelpers` and `fastify.viewFilters`.
313
304
 
314
305
  ```javascript
315
306
  fastify.register(squirrellyify, {
package/index.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { FastifyPluginAsync } from "fastify";
2
+
3
+ export interface SquirrellyifyOptions {
4
+ /**
5
+ * Enable caching of templates.
6
+ * @default process.env.NODE_ENV === 'production'
7
+ */
8
+ cache?: boolean;
9
+ /**
10
+ * Path to the views directory.
11
+ * @default './views'
12
+ */
13
+ views?: string;
14
+ /**
15
+ * Squirrelly specific configuration options overriden.
16
+ */
17
+ options?: Record<string, any>;
18
+ }
19
+
20
+ export const squirrellyify: FastifyPluginAsync<SquirrellyifyOptions>;
21
+ export default squirrellyify;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ynode/squirrellyify",
3
- "version": "1.3.0",
3
+ "version": "1.5.2",
4
4
  "description": "Fastify plugin for rendering Squirrelly templates.",
5
5
  "main": "src/plugin.js",
6
6
  "type": "module",
@@ -21,16 +21,18 @@
21
21
  "squirrelly"
22
22
  ],
23
23
  "engines": {
24
- "node": ">=18"
24
+ "node": ">=20"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@eslint/js": "^9.37.0",
28
28
  "@eslint/json": "^0.13.2",
29
29
  "@eslint/markdown": "^7.4.0",
30
30
  "@mikinho/autover": "^2.0.1",
31
+ "auto-changelog": "^2.5.0",
31
32
  "eslint": "^9.37.0",
32
33
  "eslint-config-prettier": "^10.1.8",
33
34
  "eslint-plugin-prettier": "^5.5.4",
35
+ "eslint-plugin-simple-import-sort": "^12.1.1",
34
36
  "fastify": "^5.8.1",
35
37
  "globals": "^16.4.0",
36
38
  "jsdoc": "^4.0.5",
@@ -38,6 +40,7 @@
38
40
  "rimraf": "^6.0.1"
39
41
  },
40
42
  "scripts": {
43
+ "changelog": "auto-changelog -p",
41
44
  "docs": "node scripts/gen-docs.mjs",
42
45
  "docs:clean": "rimraf docs || rmdir /s /q docs 2> NUL || true",
43
46
  "docs:open": "node -e \"import('node:child_process').then(m=>m.exec(process.platform==='win32'?'start docs/index.html':(process.platform==='darwin'?'open docs/index.html':'xdg-open docs/index.html')))\"",
@@ -49,14 +52,15 @@
49
52
  "test:staged": "node scripts/lint-staged.mjs",
50
53
  "ver:preview": "npx autover --no-amend --dry-run --short",
51
54
  "ver:apply": "npx autover --guard-unchanged --short",
52
- "test": "npm run lint && npm run test:integration",
53
- "prepublishOnly": "npm test",
55
+ "test": "node --test",
56
+ "prepublishOnly": "npm run lint && npm test",
54
57
  "postversion": "git push && git push --tags"
55
58
  },
56
59
  "publishConfig": {
57
60
  "access": "public"
58
61
  },
59
62
  "files": [
63
+ "index.d.ts",
60
64
  "src",
61
65
  "README.md",
62
66
  "LICENSE"
@@ -65,7 +69,7 @@
65
69
  "fastify-plugin": "^5.1.0"
66
70
  },
67
71
  "peerDependencies": {
68
- "fastify": "^5.0.0",
72
+ "fastify": "5.x",
69
73
  "squirrelly": "^9.1.0"
70
74
  }
71
75
  }
package/src/config.js CHANGED
@@ -19,18 +19,13 @@ function assertOptionType(condition, message) {
19
19
  function normalizeDefaultExtension(defaultExtension) {
20
20
  const normalized = defaultExtension.replace(/^\.+/, "");
21
21
  if (normalized.length === 0) {
22
- throw new TypeError(
23
- 'Invalid option "defaultExtension": must contain at least one non-dot character.',
24
- );
22
+ throw new TypeError('Invalid option "defaultExtension": must contain at least one non-dot character.');
25
23
  }
26
24
  return normalized;
27
25
  }
28
26
 
29
27
  export function validatePluginOptions(options = {}) {
30
- assertOptionType(
31
- isPlainObject(options),
32
- "Invalid options: plugin options must be a plain object.",
33
- );
28
+ assertOptionType(isPlainObject(options), "Invalid options: plugin options must be a plain object.");
34
29
 
35
30
  if (options.templates !== undefined) {
36
31
  assertOptionType(
@@ -52,8 +47,7 @@ export function validatePluginOptions(options = {}) {
52
47
  }
53
48
  if (options.partialsNamespace !== undefined) {
54
49
  assertOptionType(
55
- typeof options.partialsNamespace === "boolean" ||
56
- typeof options.partialsNamespace === "string",
50
+ typeof options.partialsNamespace === "boolean" || typeof options.partialsNamespace === "string",
57
51
  'Invalid option "partialsNamespace": expected a boolean or a string.',
58
52
  );
59
53
  }
@@ -80,10 +74,7 @@ export function validatePluginOptions(options = {}) {
80
74
  );
81
75
  }
82
76
  if (options.sqrl.config !== undefined) {
83
- assertOptionType(
84
- isPlainObject(options.sqrl.config),
85
- 'Invalid option "sqrl.config": expected an object.',
86
- );
77
+ assertOptionType(isPlainObject(options.sqrl.config), 'Invalid option "sqrl.config": expected an object.');
87
78
  }
88
79
  if (options.sqrl.helpers !== undefined) {
89
80
  assertOptionType(
@@ -130,9 +121,7 @@ export function resolveInitialPartialsDirs(options = {}) {
130
121
 
131
122
  export function resolveExtension(options = {}) {
132
123
  const defaultExtension =
133
- options.defaultExtension !== undefined
134
- ? normalizeDefaultExtension(options.defaultExtension)
135
- : "sqrl";
124
+ options.defaultExtension !== undefined ? normalizeDefaultExtension(options.defaultExtension) : "sqrl";
136
125
  return {
137
126
  defaultExtension,
138
127
  extensionWithDot: `.${defaultExtension}`,
@@ -153,15 +142,7 @@ function createScopedSqrlConfig(baseConfig) {
153
142
  const scopedFilters = new Cacher({});
154
143
  const scopedTemplates = new Cacher({});
155
144
 
156
- for (const helperName of [
157
- "each",
158
- "foreach",
159
- "include",
160
- "extends",
161
- "useScope",
162
- "includeFile",
163
- "extendsFile",
164
- ]) {
145
+ for (const helperName of ["each", "foreach", "include", "extends", "useScope", "includeFile", "extendsFile"]) {
165
146
  const helperFn = Sqrl.helpers.get(helperName);
166
147
  if (helperFn) {
167
148
  scopedHelpers.define(helperName, helperFn);
package/src/plugin.js CHANGED
@@ -31,19 +31,14 @@ import fp from "fastify-plugin";
31
31
  import Sqrl from "squirrelly";
32
32
 
33
33
  import {
34
- validatePluginOptions,
35
34
  resolveExtension,
36
35
  resolveInitialPartialsDirs,
37
36
  resolveInitialTemplateDirs,
38
37
  resolveSqrlConfig,
39
38
  resolveUseCache,
39
+ validatePluginOptions,
40
40
  } from "./config.js";
41
- import {
42
- buildTemplateSearchDirs,
43
- collectViewScope,
44
- createTemplateResolver,
45
- preloadPartials,
46
- } from "./resolver.js";
41
+ import { buildTemplateSearchDirs, collectViewScope, createTemplateResolver, preloadPartials } from "./resolver.js";
47
42
  import { createRuntimeApi } from "./runtime-api.js";
48
43
  import { assertSafeName } from "./safety.js";
49
44
 
@@ -75,20 +70,20 @@ import { assertSafeName } from "./safety.js";
75
70
  async function squirrellyify(fastify, options = {}) {
76
71
  validatePluginOptions(options);
77
72
 
73
+ if (typeof fastify.hasDecorator === "function" && fastify.hasDecorator("Sqrl")) {
74
+ throw new Error("@ynode/squirrellyify has already been registered");
75
+ }
76
+
77
+ const log =
78
+ typeof fastify.log?.child === "function" ? fastify.log.child({ name: "@ynode/squirrellyify" }) : fastify.log;
79
+
78
80
  const initialTemplatesDirs = resolveInitialTemplateDirs(options);
79
81
  const initialPartialsDirs = resolveInitialPartialsDirs(options);
80
82
  const initialLayout = options.layout;
81
83
  const { extensionWithDot } = resolveExtension(options);
82
84
  const useCache = resolveUseCache(options);
83
85
  const { sqrlScope, sqrlConfig } = resolveSqrlConfig(options);
84
- const {
85
- defineSqrlHelper,
86
- defineSqrlFilter,
87
- defineSqrlTemplate,
88
- viewHelpers,
89
- viewFilters,
90
- viewPartials,
91
- } =
86
+ const { defineSqrlHelper, defineSqrlFilter, defineSqrlTemplate, viewHelpers, viewFilters, viewPartials } =
92
87
  createRuntimeApi({
93
88
  sqrlScope,
94
89
  sqrlConfig,
@@ -131,10 +126,8 @@ async function squirrellyify(fastify, options = {}) {
131
126
  async function view(template, data = {}) {
132
127
  try {
133
128
  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 : {};
129
+ const replyContext = this.context && typeof this.context === "object" ? this.context : {};
130
+ const replyLocals = this.locals && typeof this.locals === "object" ? this.locals : {};
138
131
  const mergedData = {
139
132
  ...replyContext,
140
133
  ...replyLocals,
@@ -148,17 +141,12 @@ async function squirrellyify(fastify, options = {}) {
148
141
 
149
142
  const instance = this.request.server;
150
143
  const { aggregatedTemplatesDirs, scopedLayout } = collectViewScope(instance);
151
- const templateSearchDirs = buildTemplateSearchDirs(
152
- aggregatedTemplatesDirs,
153
- initialTemplatesDirs,
154
- );
144
+ const templateSearchDirs = buildTemplateSearchDirs(aggregatedTemplatesDirs, initialTemplatesDirs);
155
145
 
156
146
  // 1. Find and render the page template
157
147
  const pagePath = await findTemplatePath(template, templateSearchDirs);
158
148
  if (!pagePath) {
159
- throw new Error(
160
- `Template "${template}" not found in [${templateSearchDirs.join(", ")}]`,
161
- );
149
+ throw new Error(`Template "${template}" not found in [${templateSearchDirs.join(", ")}]`);
162
150
  }
163
151
 
164
152
  const pageTemplate = await getTemplate(pagePath);
@@ -179,22 +167,18 @@ async function squirrellyify(fastify, options = {}) {
179
167
  // 3. Find and render the layout, injecting the page content
180
168
  const layoutPath = await findTemplatePath(layoutFile, templateSearchDirs);
181
169
  if (!layoutPath) {
182
- throw new Error(
183
- `Layout "${layoutFile}" not found in [${templateSearchDirs.join(", ")}]`,
184
- );
170
+ throw new Error(`Layout "${layoutFile}" not found in [${templateSearchDirs.join(", ")}]`);
185
171
  }
186
172
 
187
173
  const layoutTemplate = await getTemplate(layoutPath);
188
174
  const layoutPayload =
189
- mergedData.layoutData && typeof mergedData.layoutData === "object"
190
- ? mergedData.layoutData
191
- : {};
175
+ mergedData.layoutData && typeof mergedData.layoutData === "object" ? mergedData.layoutData : {};
192
176
  const layoutData = { ...mergedData, ...layoutPayload, body: pageHtml };
193
177
  const finalHtml = await layoutTemplate(layoutData, sqrlConfig);
194
178
 
195
179
  return this.type("text/html").send(finalHtml);
196
180
  } catch (error) {
197
- this.request.server.log.error(error);
181
+ log.error(error);
198
182
  if (process.env.NODE_ENV === "production") {
199
183
  // In production, send a generic error and don't leak details
200
184
  this.status(500).send("An internal server error occurred.");
package/src/resolver.js CHANGED
@@ -79,12 +79,7 @@ export async function preloadPartials({
79
79
  const files = await collectPartialFiles(partialsDir, extensionWithDot, partialsRecursive);
80
80
  await Promise.all(
81
81
  files.map(async (partialPath) => {
82
- const partialName = resolvePartialName(
83
- partialPath,
84
- partialsDir,
85
- extensionWithDot,
86
- namespace,
87
- );
82
+ const partialName = resolvePartialName(partialPath, partialsDir, extensionWithDot, namespace);
88
83
  const content = await fs.readFile(partialPath, "utf-8");
89
84
  fastify.log.trace(`Loaded partial: ${partialName}`);
90
85
  defineSqrlTemplate(partialName, Sqrl.compile(content, sqrlConfig));
@@ -110,9 +105,7 @@ export function collectViewScope(instance) {
110
105
 
111
106
  while (currentInstance) {
112
107
  if (currentInstance.views) {
113
- const dirs = Array.isArray(currentInstance.views)
114
- ? currentInstance.views
115
- : [currentInstance.views];
108
+ const dirs = Array.isArray(currentInstance.views) ? currentInstance.views : [currentInstance.views];
116
109
  aggregatedTemplatesDirs.push(...dirs);
117
110
  }
118
111
  if (scopedLayout === null && currentInstance.layout !== null && currentInstance.layout !== undefined) {