@ynode/squirrellyify 1.2.0 → 1.5.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 CHANGED
@@ -74,26 +74,49 @@ 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 `reply.context` into the
80
+ 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.
80
102
 
81
- | Option | Type | Default | Description |
82
- | ------------------ | -------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
83
- | `templates` | `string \| string[]` | `path.join(process.cwd(), "views")` | The directory or directories to search for page and layout templates. Searched in the provided order. |
84
- | `partials` | `string \| string[]` | `[]` | The directory or directories for partial templates. All partials are loaded on startup and available by name. |
85
- | `partialsRecursive` | `boolean` | `true` | If `true`, partials are loaded recursively from subdirectories. Names use forward slashes (for example, `emails/header`). |
86
- | `partialsNamespace` | `boolean \| string` | `false` | Optional namespace prefix for partial names. Use `true` to prefix with each partials directory basename, or provide a custom string. |
87
- | `layout` | `string` | `undefined` | The name of the default layout file to use (without extension). Can be overridden per-route. |
88
- | `defaultExtension` | `string` | `"sqrl"` | The file extension for all template files. Leading `.` is optional (for example, `"html"` or `".html"`). |
89
- | `cache` | `boolean` | `NODE_ENV === "production"` | If `true`, compiled templates and resolved file paths will be cached in memory. |
90
- | `sqrl` | `object` | `undefined` | Squirrelly options. Supports `{ scope: "global" \| "scoped", config, helpers, filters }`. |
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 }`. |
91
113
 
92
114
  Runtime API after registration:
93
115
 
94
116
  - `fastify.viewHelpers.define(name, fn)`, `fastify.viewHelpers.get(name)`, `fastify.viewHelpers.remove(name)`
95
117
  - `fastify.viewFilters.define(name, fn)`, `fastify.viewFilters.get(name)`, `fastify.viewFilters.remove(name)`
96
- - `fastify.viewPartials.define(name, templateOrFn)`, `fastify.viewPartials.get(name)`, `fastify.viewPartials.remove(name)`
118
+ - `fastify.viewPartials.define(name, templateOrFn)`, `fastify.viewPartials.get(name)`,
119
+ `fastify.viewPartials.remove(name)`
97
120
  - `fastify.viewCache.clear()`, `fastify.viewCache.stats()`
98
121
 
99
122
  These APIs are scope-aware:
@@ -101,8 +124,8 @@ These APIs are scope-aware:
101
124
  - In `global` mode they modify shared helpers/filters/partials.
102
125
  - In `scoped` mode they only affect the current plugin registration scope.
103
126
 
104
- The cache API is process-local and lets you invalidate compiled template/path caches at runtime when
105
- `cache: true` is used.
127
+ The cache API is process-local and lets you invalidate compiled template/path caches at runtime when `cache: true` is
128
+ used.
106
129
 
107
130
  Invalid option types are rejected at plugin registration time with descriptive errors.
108
131
 
@@ -110,8 +133,8 @@ Invalid option types are rejected at plugin registration time with descriptive e
110
133
 
111
134
  ### Layouts
112
135
 
113
- Layouts are wrappers for your page templates. The rendered page content is injected into the `body`
114
- variable within the layout.
136
+ Layouts are wrappers for your page templates. The rendered page content is injected into the `body` variable within the
137
+ layout.
115
138
 
116
139
  **`views/layouts/main.sqrl`**
117
140
 
@@ -124,8 +147,8 @@ variable within the layout.
124
147
  <body>
125
148
  <header>My Awesome Site</header>
126
149
  <main>
127
- {{@block("content")}} {{@try}} {{it.body | safe}} {{#catch => err}} Uh-oh, error!
128
- Message was '{{err.message}}' {{/try}} {{/block}}
150
+ {{@block("content")}} {{@try}} {{it.body | safe}} {{#catch => err}} Uh-oh, error! Message was
151
+ '{{err.message}}' {{/try}} {{/block}}
129
152
  </main>
130
153
  </body>
131
154
  </html>
@@ -169,9 +192,8 @@ You can specify a layout in three ways (in order of precedence):
169
192
 
170
193
  ### Partials
171
194
 
172
- Partials are reusable chunks of template code. Create a `partials` directory and place your files
173
- there. By default, partials are loaded recursively and registered by forward-slash path from the
174
- partials directory root.
195
+ Partials are reusable chunks of template code. Create a `partials` directory and place your files there. By default,
196
+ partials are loaded recursively and registered by forward-slash path from the partials directory root.
175
197
 
176
198
  **`partials/user-card.sqrl`**
177
199
 
@@ -236,8 +258,8 @@ fastify.register(squirrellyify, {
236
258
 
237
259
  ### Scoped Configuration (Encapsulation)
238
260
 
239
- This plugin supports Fastify's encapsulation model. You can register it multiple times with
240
- different settings for different route prefixes.
261
+ This plugin supports Fastify's encapsulation model. You can register it multiple times with different settings for
262
+ different route prefixes.
241
263
 
242
264
  ```javascript
243
265
  import Fastify from "fastify";
@@ -286,8 +308,7 @@ Use `sqrl.scope` to choose registration mode:
286
308
  - `global` (default): helpers, filters, and partials are shared across plugin registrations.
287
309
  - `scoped`: helpers, filters, and partials are isolated to each plugin registration.
288
310
 
289
- You can also add/remove helpers and filters at runtime via `fastify.viewHelpers` and
290
- `fastify.viewFilters`.
311
+ You can also add/remove helpers and filters at runtime via `fastify.viewHelpers` and `fastify.viewFilters`.
291
312
 
292
313
  ```javascript
293
314
  fastify.register(squirrellyify, {
@@ -310,3 +331,4 @@ fastify.register(squirrellyify, {
310
331
  ## License
311
332
 
312
333
  This project is licensed under the [MIT License](./LICENSE).
334
+
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.2.0",
3
+ "version": "1.5.1",
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,
@@ -130,32 +125,36 @@ async function squirrellyify(fastify, options = {}) {
130
125
  */
131
126
  async function view(template, data = {}) {
132
127
  try {
128
+ const requestData = data && typeof data === "object" ? data : {};
129
+ const replyContext = this.context && typeof this.context === "object" ? this.context : {};
130
+ const replyLocals = this.locals && typeof this.locals === "object" ? this.locals : {};
131
+ const mergedData = {
132
+ ...replyContext,
133
+ ...replyLocals,
134
+ ...requestData,
135
+ };
136
+
133
137
  assertSafeName(template);
134
- if (data.layout && data.layout !== false) {
135
- assertSafeName(data.layout);
138
+ if (mergedData.layout && mergedData.layout !== false) {
139
+ assertSafeName(mergedData.layout);
136
140
  }
137
141
 
138
142
  const instance = this.request.server;
139
143
  const { aggregatedTemplatesDirs, scopedLayout } = collectViewScope(instance);
140
- const templateSearchDirs = buildTemplateSearchDirs(
141
- aggregatedTemplatesDirs,
142
- initialTemplatesDirs,
143
- );
144
+ const templateSearchDirs = buildTemplateSearchDirs(aggregatedTemplatesDirs, initialTemplatesDirs);
144
145
 
145
146
  // 1. Find and render the page template
146
147
  const pagePath = await findTemplatePath(template, templateSearchDirs);
147
148
  if (!pagePath) {
148
- throw new Error(
149
- `Template "${template}" not found in [${templateSearchDirs.join(", ")}]`,
150
- );
149
+ throw new Error(`Template "${template}" not found in [${templateSearchDirs.join(", ")}]`);
151
150
  }
152
151
 
153
152
  const pageTemplate = await getTemplate(pagePath);
154
- const pageHtml = await pageTemplate(data, sqrlConfig);
153
+ const pageHtml = await pageTemplate(mergedData, sqrlConfig);
155
154
 
156
155
  // 2. Determine which layout to use
157
156
  const currentLayout = scopedLayout !== null ? scopedLayout : initialLayout;
158
- const layoutFile = data.layout === false ? null : data.layout || currentLayout;
157
+ const layoutFile = mergedData.layout === false ? null : mergedData.layout || currentLayout;
159
158
 
160
159
  if (!layoutFile) {
161
160
  return this.type("text/html").send(pageHtml);
@@ -168,18 +167,18 @@ async function squirrellyify(fastify, options = {}) {
168
167
  // 3. Find and render the layout, injecting the page content
169
168
  const layoutPath = await findTemplatePath(layoutFile, templateSearchDirs);
170
169
  if (!layoutPath) {
171
- throw new Error(
172
- `Layout "${layoutFile}" not found in [${templateSearchDirs.join(", ")}]`,
173
- );
170
+ throw new Error(`Layout "${layoutFile}" not found in [${templateSearchDirs.join(", ")}]`);
174
171
  }
175
172
 
176
173
  const layoutTemplate = await getTemplate(layoutPath);
177
- const layoutData = { ...data, ...data.layoutData, body: pageHtml };
174
+ const layoutPayload =
175
+ mergedData.layoutData && typeof mergedData.layoutData === "object" ? mergedData.layoutData : {};
176
+ const layoutData = { ...mergedData, ...layoutPayload, body: pageHtml };
178
177
  const finalHtml = await layoutTemplate(layoutData, sqrlConfig);
179
178
 
180
179
  return this.type("text/html").send(finalHtml);
181
180
  } catch (error) {
182
- this.request.server.log.error(error);
181
+ log.error(error);
183
182
  if (process.env.NODE_ENV === "production") {
184
183
  // In production, send a generic error and don't leak details
185
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) {