@ynode/squirrellyify 1.3.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
@@ -76,8 +76,8 @@ fastify.listen({ port: 3000 }, (err) => {
76
76
 
77
77
  ### Request-Scoped View Data
78
78
 
79
- `reply.view(template, data)` automatically merges request-scoped values from `reply.locals` and
80
- `reply.context` into the template data:
79
+ `reply.view(template, data)` automatically merges request-scoped values from `reply.locals` and `reply.context` into the
80
+ template data:
81
81
 
82
82
  ```javascript
83
83
  fastify.addHook("preHandler", async (request, reply) => {
@@ -100,22 +100,23 @@ Merge precedence is:
100
100
 
101
101
  You can pass an options object when registering the plugin.
102
102
 
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 }`. |
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 }`. |
113
113
 
114
114
  Runtime API after registration:
115
115
 
116
116
  - `fastify.viewHelpers.define(name, fn)`, `fastify.viewHelpers.get(name)`, `fastify.viewHelpers.remove(name)`
117
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)`
118
+ - `fastify.viewPartials.define(name, templateOrFn)`, `fastify.viewPartials.get(name)`,
119
+ `fastify.viewPartials.remove(name)`
119
120
  - `fastify.viewCache.clear()`, `fastify.viewCache.stats()`
120
121
 
121
122
  These APIs are scope-aware:
@@ -123,8 +124,8 @@ These APIs are scope-aware:
123
124
  - In `global` mode they modify shared helpers/filters/partials.
124
125
  - In `scoped` mode they only affect the current plugin registration scope.
125
126
 
126
- The cache API is process-local and lets you invalidate compiled template/path caches at runtime when
127
- `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.
128
129
 
129
130
  Invalid option types are rejected at plugin registration time with descriptive errors.
130
131
 
@@ -132,8 +133,8 @@ Invalid option types are rejected at plugin registration time with descriptive e
132
133
 
133
134
  ### Layouts
134
135
 
135
- Layouts are wrappers for your page templates. The rendered page content is injected into the `body`
136
- 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.
137
138
 
138
139
  **`views/layouts/main.sqrl`**
139
140
 
@@ -146,8 +147,8 @@ variable within the layout.
146
147
  <body>
147
148
  <header>My Awesome Site</header>
148
149
  <main>
149
- {{@block("content")}} {{@try}} {{it.body | safe}} {{#catch => err}} Uh-oh, error!
150
- 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}}
151
152
  </main>
152
153
  </body>
153
154
  </html>
@@ -191,9 +192,8 @@ You can specify a layout in three ways (in order of precedence):
191
192
 
192
193
  ### Partials
193
194
 
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.
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.
197
197
 
198
198
  **`partials/user-card.sqrl`**
199
199
 
@@ -258,8 +258,8 @@ fastify.register(squirrellyify, {
258
258
 
259
259
  ### Scoped Configuration (Encapsulation)
260
260
 
261
- This plugin supports Fastify's encapsulation model. You can register it multiple times with
262
- 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.
263
263
 
264
264
  ```javascript
265
265
  import Fastify from "fastify";
@@ -308,8 +308,7 @@ Use `sqrl.scope` to choose registration mode:
308
308
  - `global` (default): helpers, filters, and partials are shared across plugin registrations.
309
309
  - `scoped`: helpers, filters, and partials are isolated to each plugin registration.
310
310
 
311
- You can also add/remove helpers and filters at runtime via `fastify.viewHelpers` and
312
- `fastify.viewFilters`.
311
+ You can also add/remove helpers and filters at runtime via `fastify.viewHelpers` and `fastify.viewFilters`.
313
312
 
314
313
  ```javascript
315
314
  fastify.register(squirrellyify, {
@@ -332,3 +331,4 @@ fastify.register(squirrellyify, {
332
331
  ## License
333
332
 
334
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.3.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,
@@ -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) {