@ynode/squirrellyify 1.0.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/LICENSE +20 -0
- package/README.md +247 -0
- package/package.json +65 -0
- package/src/plugin.js +275 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Michael Welter <nme@mikinho.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
7
|
+
the Software without restriction, including without limitation the rights to
|
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# @ynode/squirrellyify
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Michael Welter <me@mikinho.com>
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@ynode/squirrellyify)
|
|
6
|
+
[](https://github.com/yammm/ynode-squirrellyify/actions)
|
|
7
|
+
[](https://github.com/yammm/ynode-squirrellyify/blob/main/LICENSE)
|
|
8
|
+
|
|
9
|
+
A simple and fast plugin for using the [Squirrelly](https://squirrelly.js.org/) template engine with
|
|
10
|
+
[Fastify](https://www.fastify.io/).
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- 🐿️ **Modern Templating:** Full support for Squirrelly v9 features.
|
|
15
|
+
- ⚡ **High Performance:** Template caching is enabled by default in production.
|
|
16
|
+
- 📁 **Layouts & Partials:** Built-in support for layouts and shared partials.
|
|
17
|
+
- 🧬 **Encapsulation-Aware:** Respects Fastify's encapsulation model for s ed configurations.
|
|
18
|
+
- 🛡️ **Secure:** Protects against path traversal attacks in template names.
|
|
19
|
+
- 🔧 **Extensible:** Easily add custom Squirrelly helpers and filters.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
You need to install `squirrelly` and `fastify` alongside this plugin.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install @ynode/squirrellyify squirrelly fastify
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Basic Usage
|
|
30
|
+
|
|
31
|
+
1. **Register the plugin.**
|
|
32
|
+
2. **Use the `reply.view()` decorator in your routes.**
|
|
33
|
+
|
|
34
|
+
By default, the plugin looks for templates in a `views` directory in your project's root.
|
|
35
|
+
|
|
36
|
+
**File structure:**
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
.
|
|
40
|
+
├── views/
|
|
41
|
+
│ └── index.sqrl
|
|
42
|
+
└── server.js
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**`views/index.sqrl`**
|
|
46
|
+
|
|
47
|
+
```html
|
|
48
|
+
<h1>Hello, {{ it.name }}!</h1>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**`server.js`**
|
|
52
|
+
|
|
53
|
+
```javascript
|
|
54
|
+
import Fastify from "fastify";
|
|
55
|
+
import squirrellyify from "@ynode/squirrellyify";
|
|
56
|
+
import path from "node:path";
|
|
57
|
+
import { fileURLToPath } from "node:url";
|
|
58
|
+
|
|
59
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
60
|
+
|
|
61
|
+
const fastify = Fastify({
|
|
62
|
+
logger: true,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
fastify.register(squirrellyify, {
|
|
66
|
+
templates: path.join(__dirname, "views"),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
fastify.get("/", (request, reply) => {
|
|
70
|
+
return reply.view("index", { name: "World" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
fastify.listen({ port: 3000 }, (err) => {
|
|
74
|
+
if (err) throw err;
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Configuration Options
|
|
79
|
+
|
|
80
|
+
You can pass an options object when registering the plugin.
|
|
81
|
+
|
|
82
|
+
| Option | Type | Default | Description |
|
|
83
|
+
| ------------------ | -------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
84
|
+
| `templates` | `string \| string[]` | `path.join(process.cwd(), "views")` | The directory or directories to search for page and layout templates. Searched in the provided order. |
|
|
85
|
+
| `partials` | `string \| string[]` | `[]` | The directory or directories for partial templates. All partials are loaded on startup and are available globally by their filename. |
|
|
86
|
+
| `layout` | `string` | `undefined` | The name of the default layout file to use (without extension). Can be overridden per-route. |
|
|
87
|
+
| `defaultExtension` | `string` | `"sqrl"` | The file extension for all template files. |
|
|
88
|
+
| `cache` | `boolean` | `NODE_ENV === "production"` | If `true`, compiled templates and resolved file paths will be cached in memory. |
|
|
89
|
+
| `sqrl` | `object` | `undefined` | An object to configure Squirrelly. Currently supports `{ helpers: {}, filters: {} }` for adding custom functions. |
|
|
90
|
+
|
|
91
|
+
## Advanced Usage
|
|
92
|
+
|
|
93
|
+
### Layouts
|
|
94
|
+
|
|
95
|
+
Layouts are wrappers for your page templates. The rendered page content is injected into the `body`
|
|
96
|
+
variable within the layout.
|
|
97
|
+
|
|
98
|
+
**`views/layouts/main.sqrl`**
|
|
99
|
+
|
|
100
|
+
```html
|
|
101
|
+
<!DOCTYPE html>
|
|
102
|
+
<html lang="en">
|
|
103
|
+
<head>
|
|
104
|
+
<title>{{ it.title }}</title>
|
|
105
|
+
</head>
|
|
106
|
+
<body>
|
|
107
|
+
<header>My Awesome Site</header>
|
|
108
|
+
<main>
|
|
109
|
+
{{@block("content")}} {{@try}} {{it.body | safe}} {{#catch => err}} Uh-oh, error!
|
|
110
|
+
Message was '{{err.message}}' {{/try}} {{/block}}
|
|
111
|
+
</main>
|
|
112
|
+
</body>
|
|
113
|
+
</html>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**`views/about.sqrl`**
|
|
117
|
+
|
|
118
|
+
```html
|
|
119
|
+
{{@extends("layout", it)}} {{#content}}
|
|
120
|
+
<h2>About Us</h2>
|
|
121
|
+
<p>This is the about page content.</p>
|
|
122
|
+
|
|
123
|
+
{{/extends}}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
You can specify a layout in three ways (in order of precedence):
|
|
127
|
+
|
|
128
|
+
1. **In the `reply.view()` data object:**
|
|
129
|
+
|
|
130
|
+
```javascript
|
|
131
|
+
fastify.get("/about", (request, reply) => {
|
|
132
|
+
const pageData = { title: "About Page" };
|
|
133
|
+
// Use `main.sqrl` as the layout for this request
|
|
134
|
+
return reply.view("about", { ...pageData, layout: "layouts/main" });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// To disable the default layout for a specific route:
|
|
138
|
+
fastify.get("/no-layout", (request, reply) => {
|
|
139
|
+
return reply.view("some-page", { layout: false });
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
2. **As a default plugin option:**
|
|
144
|
+
|
|
145
|
+
```javascript
|
|
146
|
+
fastify.register(squirrellyify, {
|
|
147
|
+
templates: "views",
|
|
148
|
+
layout: "layouts/main", // All views will use this layout by default
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Partials
|
|
153
|
+
|
|
154
|
+
Partials are reusable chunks of template code. Create a `partials` directory and place your files
|
|
155
|
+
there. They will be automatically registered by their filename.
|
|
156
|
+
|
|
157
|
+
**`partials/user-card.sqrl`**
|
|
158
|
+
|
|
159
|
+
```html
|
|
160
|
+
<div class="card">
|
|
161
|
+
<h3>{{ it.name }}</h4>
|
|
162
|
+
<p>{{ it.email }}</p>
|
|
163
|
+
</div>
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**`views/index.sqrl`**
|
|
167
|
+
|
|
168
|
+
```html
|
|
169
|
+
<h1>Users</h1>
|
|
170
|
+
{{ include('user-card', { name: 'John Doe', email: 'john@example.com' }) /}}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**Register the `partials` directory:**
|
|
174
|
+
|
|
175
|
+
```javascript
|
|
176
|
+
fastify.register(squirrellyify, {
|
|
177
|
+
templates: "views",
|
|
178
|
+
partials: "partials",
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Scoped Configuration (Encapsulation)
|
|
183
|
+
|
|
184
|
+
This plugin supports Fastify's encapsulation model. You can register it multiple times with
|
|
185
|
+
different settings for different route prefixes.
|
|
186
|
+
|
|
187
|
+
```javascript
|
|
188
|
+
import Fastify from "fastify";
|
|
189
|
+
import squirrellyify from "@ynode/squirrellyify";
|
|
190
|
+
import path from "node:path";
|
|
191
|
+
|
|
192
|
+
const fastify = Fastify();
|
|
193
|
+
|
|
194
|
+
// Register with default settings
|
|
195
|
+
fastify.register(squirrellyify, {
|
|
196
|
+
templates: path.join(__dirname, "views"),
|
|
197
|
+
layout: "layouts/main",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
fastify.get("/", (req, reply) => {
|
|
201
|
+
// Renders from ./views/index.sqrl with layouts/main.sqrl
|
|
202
|
+
return reply.view("index", { title: "Homepage" });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Create a separate scope for an "admin" section
|
|
206
|
+
fastify.register(
|
|
207
|
+
(instance, opts, done) => {
|
|
208
|
+
// Override the templates directory and layout for this scope
|
|
209
|
+
instance.views = path.join(__dirname, "admin/views");
|
|
210
|
+
instance.layout = "layouts/admin";
|
|
211
|
+
|
|
212
|
+
instance.get("/", (req, reply) => {
|
|
213
|
+
// Renders from ./admin/views/dashboard.sqrl with layouts/admin.sqrl
|
|
214
|
+
return reply.view("dashboard", { title: "Admin Panel" });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
done();
|
|
218
|
+
},
|
|
219
|
+
{ prefix: "/admin" },
|
|
220
|
+
);
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Custom Helpers and Filters
|
|
224
|
+
|
|
225
|
+
You can extend Squirrelly with custom helper and filter functions via the `sqrl` option.
|
|
226
|
+
|
|
227
|
+
```javascript
|
|
228
|
+
fastify.register(squirrellyify, {
|
|
229
|
+
templates: "views",
|
|
230
|
+
sqrl: {
|
|
231
|
+
helpers: {
|
|
232
|
+
capitalize: (str) => {
|
|
233
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
filters: {
|
|
237
|
+
truncate: (str, len) => {
|
|
238
|
+
return str.length > len ? str.substring(0, len) + "..." : str;
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## License
|
|
246
|
+
|
|
247
|
+
[MIT](./LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ynode/squirrellyify",
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Fastify plugin for rendering Squirrelly templates.",
|
|
5
|
+
"main": "src/plugin.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Michael Welter <me@mikinho.com>",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/yammm/ynode-squirrellyify.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/yammm/ynode-squirrellyify/issues"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/yammm/ynode-squirrellyify#readme",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"fastify",
|
|
19
|
+
"template",
|
|
20
|
+
"view",
|
|
21
|
+
"squirrelly"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@eslint/js": "^9.37.0",
|
|
28
|
+
"@eslint/json": "^0.13.2",
|
|
29
|
+
"@eslint/markdown": "^7.4.0",
|
|
30
|
+
"@mikinho/autover": "^2.0.1",
|
|
31
|
+
"eslint": "^9.37.0",
|
|
32
|
+
"eslint-config-prettier": "^10.1.8",
|
|
33
|
+
"eslint-plugin-prettier": "^5.5.4",
|
|
34
|
+
"globals": "^16.4.0",
|
|
35
|
+
"prettier": "^3.6.2",
|
|
36
|
+
"rimraf": "^6.0.1",
|
|
37
|
+
"yuidocjs": "^0.10.2"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"docs": "node scripts/gen-docs.mjs",
|
|
41
|
+
"docs:clean": "rimraf docs || rmdir /s /q docs 2> NUL || true",
|
|
42
|
+
"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')))\"",
|
|
43
|
+
"format": "prettier --write .",
|
|
44
|
+
"format:check": "prettier --check .",
|
|
45
|
+
"lint": "eslint .",
|
|
46
|
+
"lint:fix": "eslint . --fix",
|
|
47
|
+
"ver:preview": "npx autover --no-amend --dry-run --short",
|
|
48
|
+
"ver:apply": "npx autover --guard-unchanged --short",
|
|
49
|
+
"test": "eslint --no-warn-ignored $(git diff --cached --name-only --diff-filter=ACMRTUXB | tr '\\n' ' ')",
|
|
50
|
+
"prepublishOnly": "npm test || true",
|
|
51
|
+
"postversion": "git push && git push --tags"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"files": [
|
|
57
|
+
"src",
|
|
58
|
+
"README.md",
|
|
59
|
+
"LICENSE"
|
|
60
|
+
],
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"fastify-plugin": "^5.1.0",
|
|
63
|
+
"squirrelly": "^9.1.0"
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/plugin.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Squirrelly Fastify plugin
|
|
3
|
+
*
|
|
4
|
+
* @module @ynode/squirrellyify
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
The MIT License (MIT)
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2025 Michael Welter <me@mikinho.com>
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
13
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
14
|
+
the Software without restriction, including without limitation the rights to
|
|
15
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
16
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
17
|
+
subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
24
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
25
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
26
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
27
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import fs from "node:fs/promises";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
|
|
33
|
+
import fp from "fastify-plugin";
|
|
34
|
+
import Sqrl from "squirrelly";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {import("fastify").FastifyInstance} FastifyInstance
|
|
38
|
+
* @typedef {import("fastify").FastifyReply} FastifyReply
|
|
39
|
+
* @typedef {import("squirrelly").SqrlConfig} SqrlConfig
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* This plugin adds a "view" decorator to the Fastify reply object,
|
|
44
|
+
* allowing for the rendering of Squirrelly templates with support for layouts and partials.
|
|
45
|
+
*
|
|
46
|
+
* @param {FastifyInstance} fastify The Fastify instance.
|
|
47
|
+
* @param {object} options Plugin options.
|
|
48
|
+
* @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
|
+
* @param {string|string[]} [options.partials] The directory or directories where partial templates are stored.
|
|
50
|
+
* @param {string} [options.layout] The name of the default layout file to use (without extension).
|
|
51
|
+
* @param {string} [options.defaultExtension="sqrl"] The default extension for template files.
|
|
52
|
+
* @param {boolean} [options.cache] Enables template caching. Defaults to true if NODE_ENV is "production".
|
|
53
|
+
*/
|
|
54
|
+
async function squirrellyify(fastify, options = {}) {
|
|
55
|
+
// Get initial options and set defaults from the plugin registration
|
|
56
|
+
const initialTemplatesDirs = Array.isArray(options.templates)
|
|
57
|
+
? options.templates
|
|
58
|
+
: typeof options.templates === "string"
|
|
59
|
+
? [options.templates]
|
|
60
|
+
: [path.join(process.cwd(), "views")];
|
|
61
|
+
|
|
62
|
+
const initialPartialsDirs = Array.isArray(options.partials)
|
|
63
|
+
? options.partials
|
|
64
|
+
: typeof options.partials === "string"
|
|
65
|
+
? [options.partials]
|
|
66
|
+
: [];
|
|
67
|
+
|
|
68
|
+
const initialLayout = options.layout;
|
|
69
|
+
const defaultExtension = options.defaultExtension || "sqrl";
|
|
70
|
+
const extensionWithDot = `.${defaultExtension}`;
|
|
71
|
+
const useCache = options.cache ?? process.env.NODE_ENV === "production";
|
|
72
|
+
const templateCache = new Map();
|
|
73
|
+
const pathCache = new Map();
|
|
74
|
+
const templateMeta = new Map();
|
|
75
|
+
|
|
76
|
+
// Allow passing optional Squirrelly compile/render configuration
|
|
77
|
+
const sqrlConfig = options.sqrl?.config;
|
|
78
|
+
|
|
79
|
+
// Allow Passing Custom Squirrelly Configuration
|
|
80
|
+
if (options.sqrl) {
|
|
81
|
+
if (options.sqrl.helpers) {
|
|
82
|
+
Object.entries(options.sqrl.helpers).forEach(([name, fn]) => {
|
|
83
|
+
Sqrl.helpers.define(name, fn);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (options.sqrl.filters) {
|
|
87
|
+
Object.entries(options.sqrl.filters).forEach(([name, fn]) => {
|
|
88
|
+
Sqrl.filters.define(name, fn);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Pre-load and define all partials globally on startup from all partial directories
|
|
94
|
+
if (initialPartialsDirs.length > 0) {
|
|
95
|
+
for (const partialsDir of initialPartialsDirs) {
|
|
96
|
+
try {
|
|
97
|
+
const files = await fs.readdir(partialsDir);
|
|
98
|
+
await Promise.all(
|
|
99
|
+
files.map(async (file) => {
|
|
100
|
+
if (file.endsWith(extensionWithDot)) {
|
|
101
|
+
const partialPath = path.join(partialsDir, file);
|
|
102
|
+
const partialName = path.basename(file, extensionWithDot);
|
|
103
|
+
const content = await fs.readFile(partialPath, "utf-8");
|
|
104
|
+
fastify.log.trace(`Loaded partial: ${partialName}`);
|
|
105
|
+
Sqrl.templates.define(partialName, Sqrl.compile(content, sqrlConfig));
|
|
106
|
+
}
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
fastify.log.error(`Error loading partials from ${partialsDir}: ${error.message}`);
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Compiles a template from a file path and caches it if enabled.
|
|
118
|
+
*/
|
|
119
|
+
async function getTemplate(templatePath) {
|
|
120
|
+
if (useCache && templateCache.has(templatePath)) {
|
|
121
|
+
return templateCache.get(templatePath);
|
|
122
|
+
}
|
|
123
|
+
const content = await fs.readFile(templatePath, "utf-8");
|
|
124
|
+
const hasLayoutTag = /{{\s*(?:@extends|!layout)\s*\(/.test(content);
|
|
125
|
+
const compiled = Sqrl.compile(content, sqrlConfig);
|
|
126
|
+
templateMeta.set(templatePath, { hasLayoutTag });
|
|
127
|
+
if (useCache) {
|
|
128
|
+
templateCache.set(templatePath, compiled);
|
|
129
|
+
}
|
|
130
|
+
return compiled;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Because template comes from route code, a mistaken ../ could escape the views dir.
|
|
135
|
+
* Disallow path separators and .. in template/layout names.
|
|
136
|
+
*/
|
|
137
|
+
function assertSafeName(name) {
|
|
138
|
+
if (
|
|
139
|
+
name.includes("..") ||
|
|
140
|
+
name.includes(path.sep) ||
|
|
141
|
+
name.includes("/") ||
|
|
142
|
+
name.includes("\\")
|
|
143
|
+
) {
|
|
144
|
+
throw new Error(`Illegal template name: ${name}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Renders a Squirrelly template and sends it as an HTML response.
|
|
150
|
+
* @this {FastifyReply}
|
|
151
|
+
* @param {string} template The name of the template file (without extension).
|
|
152
|
+
* @param {object} [data={}] The data to pass to the template. Can include a `layout` property to specify a layout file or set to `false` to disable layout for this request.
|
|
153
|
+
*/
|
|
154
|
+
async function view(template, data = {}) {
|
|
155
|
+
try {
|
|
156
|
+
assertSafeName(template);
|
|
157
|
+
if (data.layout && data.layout !== false) {
|
|
158
|
+
assertSafeName(data.layout);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const instance = this.request.server;
|
|
162
|
+
|
|
163
|
+
const aggregatedTemplatesDirs = [];
|
|
164
|
+
let scopedLayout = null;
|
|
165
|
+
let currentInstance = instance;
|
|
166
|
+
while (currentInstance) {
|
|
167
|
+
if (currentInstance.views) {
|
|
168
|
+
const dirs = Array.isArray(currentInstance.views)
|
|
169
|
+
? currentInstance.views
|
|
170
|
+
: [currentInstance.views];
|
|
171
|
+
aggregatedTemplatesDirs.push(...dirs);
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
scopedLayout === null &&
|
|
175
|
+
currentInstance.layout !== null &&
|
|
176
|
+
currentInstance.layout !== undefined
|
|
177
|
+
) {
|
|
178
|
+
scopedLayout = currentInstance.layout;
|
|
179
|
+
}
|
|
180
|
+
// Defensive: parent may be undefined or private
|
|
181
|
+
currentInstance = currentInstance.parent ?? null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const combinedDirs = [
|
|
185
|
+
...new Set([...aggregatedTemplatesDirs, ...initialTemplatesDirs]),
|
|
186
|
+
];
|
|
187
|
+
const allSearchDirs = [...new Set([...combinedDirs, ...initialPartialsDirs])];
|
|
188
|
+
|
|
189
|
+
async function findTemplatePath(templateName) {
|
|
190
|
+
const templateFile = `${templateName}${extensionWithDot}`;
|
|
191
|
+
const cacheKey = `${allSearchDirs.join(";")}:${templateFile}`; // Create a unique key
|
|
192
|
+
|
|
193
|
+
if (useCache && pathCache.has(cacheKey)) {
|
|
194
|
+
return pathCache.get(cacheKey);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const dir of allSearchDirs) {
|
|
198
|
+
const fullPath = path.join(dir, templateFile);
|
|
199
|
+
try {
|
|
200
|
+
await fs.access(fullPath);
|
|
201
|
+
if (useCache) {
|
|
202
|
+
pathCache.set(cacheKey, fullPath); // Cache the found path
|
|
203
|
+
}
|
|
204
|
+
return fullPath;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
fastify.log.trace(error);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 1. Find and render the page template
|
|
213
|
+
const pagePath = await findTemplatePath(template);
|
|
214
|
+
if (!pagePath) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`Template "${template}" not found in [${allSearchDirs.join(", ")}]`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const pageTemplate = await getTemplate(pagePath);
|
|
221
|
+
const pageHtml = pageTemplate(data, sqrlConfig ?? Sqrl.defaultConfig);
|
|
222
|
+
|
|
223
|
+
// 2. Determine which layout to use
|
|
224
|
+
const currentLayout = scopedLayout !== null ? scopedLayout : initialLayout;
|
|
225
|
+
const layoutFile = data.layout === false ? null : data.layout || currentLayout;
|
|
226
|
+
|
|
227
|
+
if (!layoutFile) {
|
|
228
|
+
return this.type("text/html").send(pageHtml);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const hasLayoutTag = templateMeta.get(pagePath)?.hasLayoutTag === true;
|
|
232
|
+
if (hasLayoutTag) {
|
|
233
|
+
return this.type("text/html").send(pageHtml);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 3. Find and render the layout, injecting the page content
|
|
237
|
+
const layoutPath = await findTemplatePath(layoutFile);
|
|
238
|
+
if (!layoutPath) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`Layout "${layoutFile}" not found in [${allSearchDirs.join(", ")}]`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const layoutTemplate = await getTemplate(layoutPath);
|
|
245
|
+
const layoutData = { ...data, ...data.layoutData, body: pageHtml };
|
|
246
|
+
const finalHtml = layoutTemplate(layoutData, sqrlConfig ?? Sqrl.defaultConfig);
|
|
247
|
+
|
|
248
|
+
return this.type("text/html").send(finalHtml);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
fastify.log.error(error);
|
|
251
|
+
if (process.env.NODE_ENV === "production") {
|
|
252
|
+
// In production, send a generic error and don't leak details
|
|
253
|
+
this.status(500).send("An internal server error occurred.");
|
|
254
|
+
} else {
|
|
255
|
+
// In development, it's okay to send the detailed error
|
|
256
|
+
this.code(500).send(error);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Decorate the reply object with the main view function
|
|
262
|
+
fastify.decorateReply("view", view);
|
|
263
|
+
|
|
264
|
+
// Decorate the fastify instance so users can override settings in different scopes
|
|
265
|
+
fastify.decorate("views", null);
|
|
266
|
+
fastify.decorate("layout", null);
|
|
267
|
+
|
|
268
|
+
// Also expose the Squirrelly engine itself for advanced configuration (e.g., adding helpers/filters)
|
|
269
|
+
fastify.decorate("Sqrl", Sqrl);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export default fp(squirrellyify, {
|
|
273
|
+
fastify: "5.x",
|
|
274
|
+
name: "squirrellyify",
|
|
275
|
+
});
|