vite-plugin-static-twig 1.0.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 +199 -0
- package/package.json +44 -0
- package/src/index.js +1 -0
- package/src/middleware/dist-url-rewrite.js +271 -0
- package/src/site-utils.js +99 -0
- package/src/static-pages-plugin.js +263 -0
- package/src/tasks/twig-pages.js +363 -0
package/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# vite-plugin-static-twig
|
|
2
|
+
|
|
3
|
+
A Vite plugin that compiles [Twig](https://github.com/twigjs/twig.js) templates into static HTML pages, with full dev-server HMR and multi-locale support.
|
|
4
|
+
|
|
5
|
+
- Renders all `.twig` files under a configurable pages directory to HTML at build time
|
|
6
|
+
- Watches templates and translation JSON files in dev mode and triggers a full browser reload on change
|
|
7
|
+
- Injects hashed Vite asset paths (JS / CSS) from the manifest into every rendered page
|
|
8
|
+
- Resolves bare Twig template references (`extends`, `include`, `embed`, `import`, `from`) relative to a shared templates root
|
|
9
|
+
- Serves pre-rendered HTML from the output directory via a Connect middleware during `vite dev`
|
|
10
|
+
- Computes relative `path` prefixes so pages at any nesting depth can reference root-level assets
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- **Node.js** >= 18
|
|
17
|
+
- **Vite** >= 4 (peer dependency)
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```shell
|
|
24
|
+
npm install vite-plugin-static-twig
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
// vite.config.js
|
|
33
|
+
import { defineConfig } from 'vite';
|
|
34
|
+
import staticPagesPlugin from 'vite-plugin-static-twig';
|
|
35
|
+
|
|
36
|
+
export default defineConfig({
|
|
37
|
+
plugins: [
|
|
38
|
+
staticPagesPlugin({
|
|
39
|
+
srcDir: 'src',
|
|
40
|
+
staticDir: 'src/templates/pages',
|
|
41
|
+
templatesDir: 'src/templates',
|
|
42
|
+
translationsDir: 'src/translations',
|
|
43
|
+
slugMapPath: 'src/js/json/translations.json',
|
|
44
|
+
locales: ['en', 'fr', 'nl', 'de'],
|
|
45
|
+
defaultLocale: 'en',
|
|
46
|
+
scriptsEntryKey: 'src/js/scripts.js',
|
|
47
|
+
})
|
|
48
|
+
]
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Options
|
|
55
|
+
|
|
56
|
+
All options are optional and fall back to sensible defaults.
|
|
57
|
+
|
|
58
|
+
| Option | Type | Default | Description |
|
|
59
|
+
|---|---|---|---|
|
|
60
|
+
| `srcDir` | `string` | `'src'` | Root source directory. |
|
|
61
|
+
| `staticDir` | `string` | `'src/templates/pages'` | Directory containing Twig page entry files. Files prefixed with `_` are skipped. |
|
|
62
|
+
| `templatesDir` | `string` | `'src/templates'` | Shared Twig templates directory (layouts, partials, macros). |
|
|
63
|
+
| `translationsDir` | `string` | `'src/translations'` | Directory containing JSON translation files. Must include `global.json` plus one file per locale (e.g. `en.json`). |
|
|
64
|
+
| `slugMapPath` | `string` | `'src/js/json/translations.json'` | Project-relative path to a JSON slug translation map used to build language-switcher `href` values at build time. Set to `null` to disable. |
|
|
65
|
+
| `useViteAssetsInBuild` | `boolean` | `true` | When `true`, reads the Vite manifest and injects hashed JS/CSS paths into every rendered page. |
|
|
66
|
+
| `locales` | `string[]` | `['fr','en','nl','de']` | Locale codes recognised in directory names. The locale is inferred by finding one of these as a path segment. Pass `[]` for non-localised sites. |
|
|
67
|
+
| `defaultLocale` | `string` | `'fr'` | Fallback locale used when none of the `locales` are found in the file path. Also used for pages placed at the root of `staticDir`. |
|
|
68
|
+
| `scriptsEntryKey` | `string` | `'src/js/scripts.js'` | The Vite manifest key for the JS entry point. Used to look up the hashed JS and CSS filenames. |
|
|
69
|
+
| `filters` | `Array<{ name: string, fn: Function }>` | `[]` | Additional Twig filters to register alongside the built-ins. Each entry is passed directly to `Twig.extendFilter(name, fn)`. |
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Template variables
|
|
74
|
+
|
|
75
|
+
The following variables are available in every rendered Twig page.
|
|
76
|
+
|
|
77
|
+
| Variable | Description |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `{{ locale }}` | Current language code (e.g. `en`). |
|
|
80
|
+
| `{{ path }}` | Relative prefix back to the `dist/` root (e.g. `../` for pages one level deep). Use this to prefix all asset URLs. |
|
|
81
|
+
| `{{ isProduction }}` | `true` during `vite build`. |
|
|
82
|
+
| `{{ useViteDevServer }}` | `true` during `vite dev`. |
|
|
83
|
+
| `{{ useViteAssets }}` | `true` in production when `useViteAssetsInBuild` is enabled. |
|
|
84
|
+
| `{{ viteAssets.js }}` | Hashed JS filename from the Vite manifest (production only). |
|
|
85
|
+
| `{{ viteAssets.css }}` | Hashed CSS filename from the Vite manifest (production only). |
|
|
86
|
+
| `{{ langSwitcherUrls }}` | Map of `{ targetLocale: relativeUrl }` for language-switcher links (requires `slugMapPath`). |
|
|
87
|
+
|
|
88
|
+
All top-level keys from `global.json` and the current locale JSON file are also injected as Twig variables.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Translation files
|
|
93
|
+
|
|
94
|
+
Place one JSON file per locale and a `global.json` for shared keys inside `translationsDir`:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
src/translations/
|
|
98
|
+
├── global.json ← merged into every page regardless of locale
|
|
99
|
+
├── en.json
|
|
100
|
+
├── fr.json
|
|
101
|
+
├── nl.json
|
|
102
|
+
└── de.json
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## Page conventions
|
|
108
|
+
|
|
109
|
+
- Every `.twig` file under `staticDir` that does **not** start with `_` is compiled to HTML.
|
|
110
|
+
- The locale is detected from the containing directory name (e.g. `pages/en/my-page.twig` → locale `en`).
|
|
111
|
+
- Pages at the root of `staticDir` use `defaultLocale`.
|
|
112
|
+
- Bare template references in `extends`, `include`, `embed`, `import`, and `from` tags are automatically resolved relative to `templatesDir`. Paths starting with `.` or `/` are used as-is.
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Custom Twig filters
|
|
117
|
+
|
|
118
|
+
Two filters are registered automatically.
|
|
119
|
+
|
|
120
|
+
### `external_links`
|
|
121
|
+
|
|
122
|
+
Adds `target="_blank"`, `rel="noopener noreferrer"`, and a visually hidden screen-reader label to external links and file download links inside an HTML string.
|
|
123
|
+
|
|
124
|
+
```twig
|
|
125
|
+
{{ content|external_links(locale) }}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Recognised download extensions: `pdf`, `doc`, `docx`, `xls`, `xlsx`, `pptx`, `zip`.
|
|
129
|
+
|
|
130
|
+
Screen-reader labels are resolved from a built-in map for `fr`, `nl`, `de`, and `en`. Unknown locales fall back to the French label.
|
|
131
|
+
|
|
132
|
+
### `entity_encode`
|
|
133
|
+
|
|
134
|
+
Encodes `mailto:` and `tel:` link `href` values and their visible text as HTML character entities to deter scraper harvesting.
|
|
135
|
+
|
|
136
|
+
```twig
|
|
137
|
+
{{ content|entity_encode }}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Registering additional filters
|
|
141
|
+
|
|
142
|
+
Pass a `filters` array to the plugin to register your own filters alongside the built-ins.
|
|
143
|
+
|
|
144
|
+
```js
|
|
145
|
+
// vite.config.js
|
|
146
|
+
import staticPagesPlugin from 'vite-plugin-static-twig';
|
|
147
|
+
|
|
148
|
+
export default {
|
|
149
|
+
plugins: [
|
|
150
|
+
staticPagesPlugin({
|
|
151
|
+
filters: [
|
|
152
|
+
{ name: 'uppercase', fn: (value) => value?.toUpperCase() ?? value },
|
|
153
|
+
{ name: 'prefix', fn: (value, [pfx = '']) => `${pfx}${value}` }
|
|
154
|
+
]
|
|
155
|
+
})
|
|
156
|
+
]
|
|
157
|
+
};
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Filter functions can also be imported from a separate file to keep `vite.config.js` tidy:
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
// src/twig-filters.js
|
|
164
|
+
export const filters = [
|
|
165
|
+
{ name: 'uppercase', fn: (value) => value?.toUpperCase() ?? value },
|
|
166
|
+
{ name: 'prefix', fn: (value, [pfx = '']) => `${pfx}${value}` }
|
|
167
|
+
];
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
```js
|
|
171
|
+
// vite.config.js
|
|
172
|
+
import { filters } from './src/twig-filters.js';
|
|
173
|
+
import staticPagesPlugin from 'vite-plugin-static-twig';
|
|
174
|
+
|
|
175
|
+
export default {
|
|
176
|
+
plugins: [staticPagesPlugin({ filters })]
|
|
177
|
+
};
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Each `fn` receives the filtered value as its first argument and an array of filter arguments as its second, matching the signature expected by `Twig.extendFilter`.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Publishing a new version
|
|
185
|
+
|
|
186
|
+
Pushing a tag that matches `v*` triggers the GitHub Actions workflow which runs `npm publish` automatically.
|
|
187
|
+
|
|
188
|
+
```shell
|
|
189
|
+
git tag v1.2.0
|
|
190
|
+
git push origin v1.2.0
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The workflow requires an `NPM_TOKEN` secret to be set in the GitHub repository settings (Settings → Secrets → Actions).
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vite-plugin-static-twig",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Vite plugin that renders Twig templates into static HTML pages, with dev-server HMR and multi-locale support.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"vite",
|
|
15
|
+
"vite-plugin",
|
|
16
|
+
"twig",
|
|
17
|
+
"static-site",
|
|
18
|
+
"html",
|
|
19
|
+
"i18n"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/michaeldk/vite-plugin-static-twig.git"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/michaeldk/vite-plugin-static-twig/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/michaeldk/vite-plugin-static-twig#readme",
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"twig": "^1.17.1"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"vite": ">=4"
|
|
38
|
+
},
|
|
39
|
+
"peerDependenciesMeta": {
|
|
40
|
+
"vite": {
|
|
41
|
+
"optional": false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './static-pages-plugin.js';
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a Connect-compatible middleware that rewrites incoming request URLs
|
|
6
|
+
* to their pre-built counterparts in the Vite output directory (`outDir`).
|
|
7
|
+
*
|
|
8
|
+
* This allows the Vite dev server to serve the statically rendered HTML pages
|
|
9
|
+
* (written to `outDir` by the Twig render step) as if they were first-class
|
|
10
|
+
* dev-server routes. For example, a request for `/fr/about` is transparently
|
|
11
|
+
* rewritten to `/<outDir>/fr/about.html`.
|
|
12
|
+
*
|
|
13
|
+
* URL resolution is cached with a short TTL and invalidated whenever the file
|
|
14
|
+
* watcher reports a change inside `outDir`. Vite-internal paths (`/@vite`,
|
|
15
|
+
* `/@fs`, etc.) are always bypassed without touching the cache.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} options
|
|
18
|
+
* @param {string} options.projectRoot - Absolute path to the project root.
|
|
19
|
+
* @param {string} options.outDir - Absolute path to the Vite output directory.
|
|
20
|
+
* @param {import('chokidar').FSWatcher} options.watcher - Vite's file watcher instance.
|
|
21
|
+
* @returns {import('connect').HandleFunction} Express/Connect middleware function.
|
|
22
|
+
*/
|
|
23
|
+
function createDistUrlRewrite(options) {
|
|
24
|
+
const { projectRoot, outDir, watcher } = options;
|
|
25
|
+
const STAT_CACHE_TTL_MS = 1000;
|
|
26
|
+
const RESOLUTION_CACHE_TTL_MS = 1000;
|
|
27
|
+
const MAX_CACHE_ENTRIES = 1024;
|
|
28
|
+
const fileStatCache = new Map();
|
|
29
|
+
const resolutionCache = new Map();
|
|
30
|
+
const inFlightResolution = new Map();
|
|
31
|
+
const distBasePath = getDistPublicBasePath();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns the URL base path under which the output directory is served,
|
|
35
|
+
* expressed as an absolute-style path string (e.g. `'/dist'` or `'/'`).
|
|
36
|
+
* Used to construct rewritten URLs and to skip requests that already point
|
|
37
|
+
* into the output directory.
|
|
38
|
+
*
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function getDistPublicBasePath() {
|
|
42
|
+
const relativeOutDir = path.relative(projectRoot, outDir).split(path.sep).join('/');
|
|
43
|
+
return relativeOutDir ? `/${relativeOutDir}` : '/';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Evicts the oldest entry from `map` if it has reached `MAX_CACHE_ENTRIES`.
|
|
48
|
+
* Keeps memory usage bounded without a full flush.
|
|
49
|
+
*
|
|
50
|
+
* @param {Map} map
|
|
51
|
+
*/
|
|
52
|
+
function trimCache(map) {
|
|
53
|
+
if (map.size < MAX_CACHE_ENTRIES) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const firstKey = map.keys().next().value;
|
|
57
|
+
if (firstKey !== undefined) {
|
|
58
|
+
map.delete(firstKey);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Clears all caches (file-stat, resolution, and in-flight).
|
|
64
|
+
* Called by the watcher when a file inside `outDir` changes, ensuring
|
|
65
|
+
* stale entries are not served after a re-render.
|
|
66
|
+
*/
|
|
67
|
+
function clearCaches() {
|
|
68
|
+
fileStatCache.clear();
|
|
69
|
+
resolutionCache.clear();
|
|
70
|
+
inFlightResolution.clear();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (watcher && typeof watcher.on === 'function') {
|
|
74
|
+
const invalidateOnSourceChange = (filePath) => {
|
|
75
|
+
const absolutePath = path.resolve(projectRoot, filePath);
|
|
76
|
+
if (isWithinOutDir(absolutePath)) {
|
|
77
|
+
clearCaches();
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
watcher.on('add', invalidateOnSourceChange);
|
|
81
|
+
watcher.on('change', invalidateOnSourceChange);
|
|
82
|
+
watcher.on('unlink', invalidateOnSourceChange);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Returns true if `absPath` is equal to, or nested inside, the resolved
|
|
87
|
+
* output directory. Used to guard against path-traversal candidates.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} absPath - Absolute path to check.
|
|
90
|
+
* @returns {boolean}
|
|
91
|
+
*/
|
|
92
|
+
function isWithinOutDir(absPath) {
|
|
93
|
+
const normalizedOutDir = path.resolve(outDir);
|
|
94
|
+
const normalizedTarget = path.resolve(absPath);
|
|
95
|
+
return (
|
|
96
|
+
normalizedTarget === normalizedOutDir ||
|
|
97
|
+
normalizedTarget.startsWith(`${normalizedOutDir}${path.sep}`)
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Produces an ordered list of candidate file paths (relative to `outDir`)
|
|
103
|
+
* that could satisfy a given URL pathname. Resolution order:
|
|
104
|
+
* - `/` → `index.html`
|
|
105
|
+
* - `/foo/` → `foo/index.html`
|
|
106
|
+
* - `/foo.ext` → `foo.ext` (has extension — only one candidate)
|
|
107
|
+
* - `/foo` → `foo`, `foo.html`, `foo/index.html`
|
|
108
|
+
*
|
|
109
|
+
* @param {string} pathname - URL pathname (e.g. `/fr/about`).
|
|
110
|
+
* @returns {string[]} Ordered candidates, relative to `outDir`.
|
|
111
|
+
*/
|
|
112
|
+
function buildDistCandidates(pathname) {
|
|
113
|
+
const cleaned = pathname.replace(/^\/+/, '');
|
|
114
|
+
if (!cleaned) {
|
|
115
|
+
return ['index.html'];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (cleaned.endsWith('/')) {
|
|
119
|
+
return [`${cleaned}index.html`];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (path.extname(cleaned)) {
|
|
123
|
+
return [cleaned];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return [cleaned, `${cleaned}.html`, `${cleaned}/index.html`];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Checks whether `candidatePath` points to an existing file, with results
|
|
131
|
+
* cached for `STAT_CACHE_TTL_MS` milliseconds to avoid repeated syscalls.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} candidatePath - Absolute path to check.
|
|
134
|
+
* @returns {Promise<boolean>}
|
|
135
|
+
*/
|
|
136
|
+
async function isExistingFile(candidatePath) {
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
const cached = fileStatCache.get(candidatePath);
|
|
139
|
+
if (cached && cached.expiresAt > now) {
|
|
140
|
+
return cached.isFile;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let isFile = false;
|
|
144
|
+
try {
|
|
145
|
+
const stat = await fs.promises.stat(candidatePath);
|
|
146
|
+
isFile = stat.isFile();
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error && error.code !== 'ENOENT' && error.code !== 'ENOTDIR') {
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
trimCache(fileStatCache);
|
|
154
|
+
fileStatCache.set(candidatePath, {
|
|
155
|
+
isFile,
|
|
156
|
+
expiresAt: now + STAT_CACHE_TTL_MS
|
|
157
|
+
});
|
|
158
|
+
return isFile;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolves a URL pathname to an existing file path relative to `outDir`
|
|
163
|
+
* by trying the candidates returned by `buildDistCandidates` in order.
|
|
164
|
+
*
|
|
165
|
+
* Results are cached for `RESOLUTION_CACHE_TTL_MS` milliseconds.
|
|
166
|
+
* Concurrent calls for the same pathname share a single in-flight promise
|
|
167
|
+
* to prevent duplicate filesystem lookups.
|
|
168
|
+
*
|
|
169
|
+
* @param {string} pathname - URL pathname to resolve (e.g. `/fr/about`).
|
|
170
|
+
* @returns {Promise<string|null>} Relative path inside `outDir`, or `null` if not found.
|
|
171
|
+
*/
|
|
172
|
+
async function resolveExistingDistPath(pathname) {
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
const cached = resolutionCache.get(pathname);
|
|
175
|
+
if (cached && cached.expiresAt > now) {
|
|
176
|
+
return cached.value;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (inFlightResolution.has(pathname)) {
|
|
180
|
+
return inFlightResolution.get(pathname);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const resolutionPromise = (async () => {
|
|
184
|
+
const candidates = buildDistCandidates(pathname);
|
|
185
|
+
for (const candidate of candidates) {
|
|
186
|
+
const candidatePath = path.resolve(outDir, candidate);
|
|
187
|
+
if (!isWithinOutDir(candidatePath)) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (await isExistingFile(candidatePath)) {
|
|
191
|
+
trimCache(resolutionCache);
|
|
192
|
+
resolutionCache.set(pathname, {
|
|
193
|
+
value: candidate,
|
|
194
|
+
expiresAt: Date.now() + RESOLUTION_CACHE_TTL_MS
|
|
195
|
+
});
|
|
196
|
+
return candidate;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
trimCache(resolutionCache);
|
|
201
|
+
resolutionCache.set(pathname, {
|
|
202
|
+
value: null,
|
|
203
|
+
expiresAt: Date.now() + RESOLUTION_CACHE_TTL_MS
|
|
204
|
+
});
|
|
205
|
+
return null;
|
|
206
|
+
})();
|
|
207
|
+
|
|
208
|
+
inFlightResolution.set(pathname, resolutionPromise);
|
|
209
|
+
try {
|
|
210
|
+
return await resolutionPromise;
|
|
211
|
+
} finally {
|
|
212
|
+
inFlightResolution.delete(pathname);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Connect middleware. Rewrites `req.url` for GET/HEAD requests whose
|
|
218
|
+
* pathname maps to an existing file in `outDir`, then calls `next()`.
|
|
219
|
+
* Passes through unchanged for Vite-internal paths, unresolvable paths,
|
|
220
|
+
* and non-GET/HEAD methods.
|
|
221
|
+
*
|
|
222
|
+
* @param {import('http').IncomingMessage} req
|
|
223
|
+
* @param {import('http').ServerResponse} res
|
|
224
|
+
* @param {Function} next
|
|
225
|
+
* @returns {Promise<void>}
|
|
226
|
+
*/
|
|
227
|
+
return async function distUrlRewriteMiddleware(req, res, next) {
|
|
228
|
+
if (!req.url || (req.method !== 'GET' && req.method !== 'HEAD')) {
|
|
229
|
+
return next();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let parsed;
|
|
233
|
+
try {
|
|
234
|
+
parsed = new URL(req.url, 'http://localhost');
|
|
235
|
+
} catch (_error) {
|
|
236
|
+
return next();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const pathname = parsed.pathname;
|
|
240
|
+
const shouldBypass =
|
|
241
|
+
pathname.startsWith('/@vite') ||
|
|
242
|
+
pathname.startsWith('/@fs/') ||
|
|
243
|
+
pathname.startsWith('/@id/') ||
|
|
244
|
+
pathname.startsWith('/__vite') ||
|
|
245
|
+
pathname.startsWith('/node_modules/') ||
|
|
246
|
+
pathname.startsWith('/src/') ||
|
|
247
|
+
pathname.startsWith(`${distBasePath}/`);
|
|
248
|
+
|
|
249
|
+
if (shouldBypass) {
|
|
250
|
+
return next();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
const matchedDistPath = await resolveExistingDistPath(pathname);
|
|
255
|
+
if (!matchedDistPath) {
|
|
256
|
+
return next();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
req.url =
|
|
260
|
+
distBasePath === '/'
|
|
261
|
+
? `/${matchedDistPath}${parsed.search}`
|
|
262
|
+
: `${distBasePath}/${matchedDistPath}${parsed.search}`;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
console.error('[dist-url-rewrite] path resolution failed:', error);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return next();
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export { createDistUrlRewrite };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import fsp from 'fs/promises';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a collection of file-system utility helpers scoped to a given
|
|
7
|
+
* project root. Paths that begin with an underscore-prefixed segment are
|
|
8
|
+
* treated as private and are skipped during directory walks.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} projectRoot - Absolute path to the project root.
|
|
11
|
+
* @returns {{ walkFiles: Function, ensureDir: Function, copyFileWithParents: Function, loadJson: Function }}
|
|
12
|
+
*/
|
|
13
|
+
function createSiteUtils(projectRoot) {
|
|
14
|
+
/**
|
|
15
|
+
* Returns true if any segment of `absPath` (relative to `projectRoot`)
|
|
16
|
+
* starts with an underscore, indicating the path should be ignored.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} absPath - Absolute path to test.
|
|
19
|
+
* @returns {boolean}
|
|
20
|
+
*/
|
|
21
|
+
function shouldIgnorePath(absPath) {
|
|
22
|
+
const rel = path.relative(projectRoot, absPath);
|
|
23
|
+
const segments = rel.split(path.sep);
|
|
24
|
+
return segments.some((segment) => segment.startsWith('_'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Recursively collects all file paths inside `dirPath`, skipping any
|
|
29
|
+
* entry whose path contains an underscore-prefixed segment.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} dirPath - Absolute path of the directory to walk.
|
|
32
|
+
* @returns {Promise<string[]>} Flat list of absolute file paths.
|
|
33
|
+
*/
|
|
34
|
+
async function walkFiles(dirPath) {
|
|
35
|
+
const entries = await fsp.readdir(dirPath, { withFileTypes: true });
|
|
36
|
+
const files = [];
|
|
37
|
+
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
const entryPath = path.join(dirPath, entry.name);
|
|
40
|
+
if (shouldIgnorePath(entryPath)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
files.push(...(await walkFiles(entryPath)));
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
files.push(entryPath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return files;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Creates `dirPath` and any missing parent directories (equivalent to `mkdir -p`).
|
|
57
|
+
*
|
|
58
|
+
* @param {string} dirPath - Absolute path of the directory to create.
|
|
59
|
+
* @returns {Promise<void>}
|
|
60
|
+
*/
|
|
61
|
+
async function ensureDir(dirPath) {
|
|
62
|
+
await fsp.mkdir(dirPath, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Copies a file to `targetPath`, creating any missing parent directories first.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} sourcePath - Absolute path of the source file.
|
|
69
|
+
* @param {string} targetPath - Absolute path of the destination file.
|
|
70
|
+
* @returns {Promise<void>}
|
|
71
|
+
*/
|
|
72
|
+
async function copyFileWithParents(sourcePath, targetPath) {
|
|
73
|
+
await ensureDir(path.dirname(targetPath));
|
|
74
|
+
await fsp.copyFile(sourcePath, targetPath);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Reads and parses a JSON file. Returns an empty object if the file does
|
|
79
|
+
* not exist, so callers can safely spread the result without null-checking.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} jsonPath - Absolute path to the JSON file.
|
|
82
|
+
* @returns {Promise<object>}
|
|
83
|
+
*/
|
|
84
|
+
async function loadJson(jsonPath) {
|
|
85
|
+
if (!fs.existsSync(jsonPath)) {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
return JSON.parse(await fsp.readFile(jsonPath, 'utf8'));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
walkFiles,
|
|
93
|
+
ensureDir,
|
|
94
|
+
copyFileWithParents,
|
|
95
|
+
loadJson
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export { createSiteUtils };
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { createSiteUtils } from './site-utils.js';
|
|
3
|
+
import { createTwigPagesTask } from './tasks/twig-pages.js';
|
|
4
|
+
import { createDistUrlRewrite } from './middleware/dist-url-rewrite.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Vite plugin that renders Twig templates into static HTML pages during both
|
|
8
|
+
* development (via a dev-server middleware) and production builds.
|
|
9
|
+
*
|
|
10
|
+
* In dev mode the plugin watches the source directories and triggers a full
|
|
11
|
+
* browser reload whenever a relevant file changes, using a render-coalescing
|
|
12
|
+
* strategy to avoid concurrent renders.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} [options]
|
|
15
|
+
* @param {string} [options.srcDir='src'] - Root source directory.
|
|
16
|
+
* @param {string} [options.staticDir='src/templates/pages'] - Twig page entry files.
|
|
17
|
+
* @param {string} [options.templatesDir='src/templates'] - Shared Twig templates.
|
|
18
|
+
* @param {string} [options.translationsDir='src/translations'] - JSON translation files.
|
|
19
|
+
* @param {string} [options.slugMapPath='src/js/json/translations.json'] - URL slug translation map used to build lang-switcher hrefs at build time.
|
|
20
|
+
* @param {boolean} [options.useViteAssetsInBuild=true] - Inject Vite manifest assets into rendered HTML.
|
|
21
|
+
* @param {string[]} [options.locales=['fr','en','nl','de']] - Locale codes to detect from directory names.
|
|
22
|
+
* @param {string} [options.defaultLocale='fr'] - Fallback locale when none is detected from the path.
|
|
23
|
+
* @param {string} [options.scriptsEntryKey='src/js/scripts.js'] - Vite manifest key for the JS/CSS entry point.
|
|
24
|
+
* @param {Array<{name:string, fn:Function}>} [options.filters=[]] - Additional Twig filters to register. Each entry is `{ name, fn }` passed directly to `Twig.extendFilter`.
|
|
25
|
+
* @returns {import('vite').Plugin}
|
|
26
|
+
*/
|
|
27
|
+
function staticPagesPlugin(options = {}) {
|
|
28
|
+
const {
|
|
29
|
+
srcDir = 'src',
|
|
30
|
+
staticDir = 'src/templates/pages',
|
|
31
|
+
templatesDir = 'src/templates',
|
|
32
|
+
translationsDir = 'src/translations',
|
|
33
|
+
slugMapPath = 'src/js/json/translations.json',
|
|
34
|
+
useViteAssetsInBuild = true,
|
|
35
|
+
locales = ['fr', 'en', 'nl', 'de'],
|
|
36
|
+
defaultLocale = 'fr',
|
|
37
|
+
scriptsEntryKey = 'src/js/scripts.js',
|
|
38
|
+
filters = []
|
|
39
|
+
} = options;
|
|
40
|
+
|
|
41
|
+
let config;
|
|
42
|
+
let projectRoot;
|
|
43
|
+
let devServer = null;
|
|
44
|
+
let tasks = null;
|
|
45
|
+
|
|
46
|
+
// Render coalescing: if a file changes while a render is already running,
|
|
47
|
+
// we don't start a second concurrent render. Instead we set rerenderQueued=true
|
|
48
|
+
// so the active render loops once more when done, draining all pending requests.
|
|
49
|
+
let isRendering = false;
|
|
50
|
+
let rerenderQueued = false;
|
|
51
|
+
let fullReloadQueued = false;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Instantiates the utility helpers and the Twig render task, bound to the
|
|
55
|
+
* resolved Vite config. Called once inside `configResolved`.
|
|
56
|
+
*
|
|
57
|
+
* @returns {{ utils: object, twigPagesTask: object }}
|
|
58
|
+
*/
|
|
59
|
+
function buildTasks() {
|
|
60
|
+
const utils = createSiteUtils(projectRoot);
|
|
61
|
+
const twigPagesTask = createTwigPagesTask({
|
|
62
|
+
srcDir,
|
|
63
|
+
staticDir,
|
|
64
|
+
templatesDir,
|
|
65
|
+
translationsDir,
|
|
66
|
+
slugMapPath,
|
|
67
|
+
useViteAssetsInBuild,
|
|
68
|
+
locales,
|
|
69
|
+
defaultLocale,
|
|
70
|
+
scriptsEntryKey,
|
|
71
|
+
filters,
|
|
72
|
+
projectRoot,
|
|
73
|
+
outDir: path.resolve(projectRoot, config.build.outDir),
|
|
74
|
+
walkFiles: utils.walkFiles,
|
|
75
|
+
ensureDir: utils.ensureDir,
|
|
76
|
+
loadJson: utils.loadJson
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
utils,
|
|
81
|
+
twigPagesTask
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Returns true if `absPath` is inside any of the three watched source
|
|
87
|
+
* directories (static pages, templates, or translations).
|
|
88
|
+
* Does **not** filter by file extension.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} absPath - Absolute path to check.
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
function isInWatchedDirectory(absPath) {
|
|
94
|
+
const relativePath = path.relative(projectRoot, absPath);
|
|
95
|
+
if (relativePath.startsWith('..')) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const staticPrefix = `${staticDir}${path.sep}`;
|
|
100
|
+
const templatesPrefix = `${templatesDir}${path.sep}`;
|
|
101
|
+
const translationsPrefix = `${translationsDir}${path.sep}`;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
relativePath === staticDir ||
|
|
105
|
+
relativePath === templatesDir ||
|
|
106
|
+
relativePath === translationsDir ||
|
|
107
|
+
relativePath.startsWith(staticPrefix) ||
|
|
108
|
+
relativePath.startsWith(templatesPrefix) ||
|
|
109
|
+
relativePath.startsWith(translationsPrefix)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Registers every source file in the watched directories with Vite's watcher
|
|
115
|
+
* so that `hotUpdate` is triggered when they change. Only called in dev mode.
|
|
116
|
+
*
|
|
117
|
+
* @param {import('vite').BuildContext} ctx - The Vite `buildStart` hook context.
|
|
118
|
+
* @returns {Promise<void>}
|
|
119
|
+
*/
|
|
120
|
+
async function addWatchFiles(ctx) {
|
|
121
|
+
const staticRoot = path.join(projectRoot, staticDir);
|
|
122
|
+
const templatesRoot = path.join(projectRoot, templatesDir);
|
|
123
|
+
const translationsRoot = path.join(projectRoot, translationsDir);
|
|
124
|
+
|
|
125
|
+
const [staticFiles, templateFiles, translationFiles] = await Promise.all([
|
|
126
|
+
tasks.utils.walkFiles(staticRoot),
|
|
127
|
+
tasks.utils.walkFiles(templatesRoot),
|
|
128
|
+
tasks.utils.walkFiles(translationsRoot)
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
const twigFiles = staticFiles.filter((filePath) => path.extname(filePath).toLowerCase() === '.twig');
|
|
132
|
+
const extraFiles = slugMapPath ? [path.resolve(projectRoot, slugMapPath)] : [];
|
|
133
|
+
for (const filePath of [...twigFiles, ...templateFiles, ...translationFiles, ...extraFiles]) {
|
|
134
|
+
ctx.addWatchFile(filePath);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Builds the context object passed to `twigPagesTask.renderTwigPages`,
|
|
140
|
+
* derived from the current Vite command (`serve` vs `build`).
|
|
141
|
+
*
|
|
142
|
+
* @returns {{ isBuild: boolean, useViteDevServer: boolean, viteDevBase: string }}
|
|
143
|
+
*/
|
|
144
|
+
function createRenderContext() {
|
|
145
|
+
return {
|
|
146
|
+
isBuild: config.command === 'build',
|
|
147
|
+
useViteDevServer: config.command === 'serve',
|
|
148
|
+
viteDevBase: '/'
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Triggers a Twig re-render. Calls made while a render is already in progress
|
|
154
|
+
* are coalesced — the render loop processes them in sequence, never in parallel.
|
|
155
|
+
* @param {boolean} fullReload - Whether to send a full browser reload after rendering.
|
|
156
|
+
*/
|
|
157
|
+
async function renderTwigPages(fullReload = false) {
|
|
158
|
+
rerenderQueued = true;
|
|
159
|
+
fullReloadQueued ||= fullReload;
|
|
160
|
+
if (isRendering) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
isRendering = true;
|
|
165
|
+
try {
|
|
166
|
+
while (rerenderQueued) {
|
|
167
|
+
const shouldReload = fullReloadQueued;
|
|
168
|
+
rerenderQueued = false;
|
|
169
|
+
fullReloadQueued = false;
|
|
170
|
+
|
|
171
|
+
await tasks.twigPagesTask.renderTwigPages(createRenderContext());
|
|
172
|
+
if (shouldReload && devServer) {
|
|
173
|
+
devServer.ws.send({ type: 'full-reload' });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch (error) {
|
|
177
|
+
console.error('[static-pages-plugin] render failed:', error);
|
|
178
|
+
} finally {
|
|
179
|
+
isRendering = false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Determines whether a changed file should trigger a full Twig re-render.
|
|
185
|
+
*
|
|
186
|
+
* Returns `true` when the file is:
|
|
187
|
+
* - a `.twig` file inside any watched directory, OR
|
|
188
|
+
* - any file inside the templates or translations directories (changing a
|
|
189
|
+
* layout or a translation string always invalidates every page).
|
|
190
|
+
*
|
|
191
|
+
* Returns `false` for paths outside the watched directories or for unrelated
|
|
192
|
+
* file types (e.g. static images inside the pages tree).
|
|
193
|
+
*
|
|
194
|
+
* @param {string} filePath - Absolute or project-relative path of the changed file.
|
|
195
|
+
* @returns {boolean}
|
|
196
|
+
*/
|
|
197
|
+
function needsRerender(filePath) {
|
|
198
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(projectRoot, filePath);
|
|
199
|
+
|
|
200
|
+
if (slugMapPath && absolutePath === path.resolve(projectRoot, slugMapPath)) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!isInWatchedDirectory(absolutePath)) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const relativePath = path.relative(projectRoot, absolutePath);
|
|
209
|
+
const extension = path.extname(relativePath).toLowerCase();
|
|
210
|
+
const isTwig = extension === '.twig';
|
|
211
|
+
const templatesPrefix = `${templatesDir}${path.sep}`;
|
|
212
|
+
const translationsPrefix = `${translationsDir}${path.sep}`;
|
|
213
|
+
const isTemplateOrTranslation =
|
|
214
|
+
relativePath === templatesDir ||
|
|
215
|
+
relativePath === translationsDir ||
|
|
216
|
+
relativePath.startsWith(templatesPrefix) ||
|
|
217
|
+
relativePath.startsWith(translationsPrefix);
|
|
218
|
+
|
|
219
|
+
return isTwig || isTemplateOrTranslation;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
name: 'static-pages-plugin',
|
|
224
|
+
configResolved(resolvedConfig) {
|
|
225
|
+
config = resolvedConfig;
|
|
226
|
+
projectRoot = resolvedConfig.root;
|
|
227
|
+
tasks = buildTasks();
|
|
228
|
+
},
|
|
229
|
+
async buildStart() {
|
|
230
|
+
// Only register watch files in dev mode; build mode does not need them.
|
|
231
|
+
if (config.command === 'serve') {
|
|
232
|
+
await addWatchFiles(this);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
async closeBundle() {
|
|
236
|
+
await tasks.twigPagesTask.renderTwigPages(createRenderContext());
|
|
237
|
+
console.log('Twig pages generated in output directory.');
|
|
238
|
+
},
|
|
239
|
+
async configureServer(server) {
|
|
240
|
+
devServer = server;
|
|
241
|
+
await renderTwigPages(false);
|
|
242
|
+
server.middlewares.use(
|
|
243
|
+
createDistUrlRewrite({
|
|
244
|
+
projectRoot,
|
|
245
|
+
outDir: path.resolve(projectRoot, config.build.outDir),
|
|
246
|
+
watcher: server.watcher
|
|
247
|
+
})
|
|
248
|
+
);
|
|
249
|
+
},
|
|
250
|
+
hotUpdate({ file }) {
|
|
251
|
+
if (needsRerender(file)) {
|
|
252
|
+
// Fire-and-forget: hotUpdate must return synchronously, so we do not
|
|
253
|
+
// await. The render coalescing logic handles concurrent calls safely.
|
|
254
|
+
// Return empty array to suppress default HMR; renderTwigPages sends
|
|
255
|
+
// a full-reload itself once re-rendering is complete.
|
|
256
|
+
void renderTwigPages(true);
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export default staticPagesPlugin;
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import Twig from 'twig';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a task that renders Twig page templates into static HTML files.
|
|
7
|
+
*
|
|
8
|
+
* Each `.twig` file under `staticDir` (excluding underscore-prefixed files)
|
|
9
|
+
* is treated as a page entry. Language is inferred from the directory structure,
|
|
10
|
+
* translations are loaded per language, and asset paths are made relative to
|
|
11
|
+
* each output file's location.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} options
|
|
14
|
+
* @param {string} options.srcDir - Root source directory.
|
|
15
|
+
* @param {string} options.staticDir - Directory containing Twig page entries.
|
|
16
|
+
* @param {string} options.templatesDir - Shared Twig templates directory.
|
|
17
|
+
* @param {string} options.translationsDir - Directory containing JSON translation files.
|
|
18
|
+
* @param {string} [options.slugMapPath] - Project-relative path to the URL slug translation map (JSON).
|
|
19
|
+
* @param {boolean} options.useViteAssetsInBuild - Whether to inject Vite manifest assets.
|
|
20
|
+
* @param {string[]} [options.locales] - List of locale codes to detect from directory names. Defaults to `['fr','en','nl','de']`.
|
|
21
|
+
* @param {string} [options.defaultLocale] - Locale used when none is detected from the path. Defaults to `'fr'`.
|
|
22
|
+
* @param {string} [options.scriptsEntryKey] - Vite manifest key for the JS entry point. Defaults to `'src/js/scripts.js'`.
|
|
23
|
+
* @param {string} options.projectRoot - Absolute project root path.
|
|
24
|
+
* @param {string} options.outDir - Absolute output directory path.
|
|
25
|
+
* @param {Function} options.walkFiles - Async function to list files recursively.
|
|
26
|
+
* @param {Function} options.ensureDir - Async function to create a directory tree.
|
|
27
|
+
* @param {Function} options.loadJson - Async function to load a JSON file safely.
|
|
28
|
+
* @param {Array<{name:string, fn:Function}>} [options.filters=[]] - Additional Twig filters to register alongside the built-ins.
|
|
29
|
+
* @returns {{ renderTwigPages: Function }}
|
|
30
|
+
*/
|
|
31
|
+
function createTwigPagesTask(options) {
|
|
32
|
+
const {
|
|
33
|
+
srcDir,
|
|
34
|
+
staticDir,
|
|
35
|
+
templatesDir,
|
|
36
|
+
translationsDir,
|
|
37
|
+
slugMapPath,
|
|
38
|
+
useViteAssetsInBuild,
|
|
39
|
+
locales = ['fr', 'en', 'nl', 'de'],
|
|
40
|
+
defaultLocale = 'fr',
|
|
41
|
+
scriptsEntryKey = 'src/js/scripts.js',
|
|
42
|
+
filters = [],
|
|
43
|
+
projectRoot,
|
|
44
|
+
outDir,
|
|
45
|
+
walkFiles,
|
|
46
|
+
ensureDir,
|
|
47
|
+
loadJson
|
|
48
|
+
} = options;
|
|
49
|
+
|
|
50
|
+
const localePattern = new RegExp(`[\\\\/](${locales.join('|')})[\\\\/]`);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Infers the locale from a file path by looking for a language-code segment
|
|
54
|
+
* matching one of the configured `locales`. Defaults to `defaultLocale` if
|
|
55
|
+
* none is found.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} filePath - Absolute or relative path to the Twig file.
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
function detectLanguage(filePath) {
|
|
61
|
+
const match = filePath.match(localePattern);
|
|
62
|
+
return match ? match[1] : defaultLocale;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Calculates the relative path prefix needed to reach the asset root from
|
|
67
|
+
* the output HTML file's location (e.g. `'../../'` for a file two levels deep).
|
|
68
|
+
* Returns an empty string for files at the root of the output directory.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} filePath - Absolute path to the source `.twig` file.
|
|
71
|
+
* @param {string} staticRoot - Absolute path to the static pages root.
|
|
72
|
+
* @param {string} outputRoot - Absolute path to the build output directory.
|
|
73
|
+
* @returns {string} Relative prefix ending with `/`, or empty string.
|
|
74
|
+
*/
|
|
75
|
+
function calculateAssetPath(filePath, staticRoot, outputRoot) {
|
|
76
|
+
const outputPath = filePath.replace(/\.twig$/, '.html').replace(staticRoot, outputRoot);
|
|
77
|
+
const relativePath = path.relative(outputRoot, outputPath);
|
|
78
|
+
const dirname = path.dirname(relativePath);
|
|
79
|
+
const depth = dirname === '.' ? 0 : dirname.split(path.sep).length;
|
|
80
|
+
return depth > 0 ? '../'.repeat(depth) : '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Builds a map of `{ targetLang: relativeUrl }` for the language-switcher
|
|
85
|
+
* links of a given page, resolved at build time from the slug translation map.
|
|
86
|
+
*
|
|
87
|
+
* For example, for `de/unbefristete-kombinierte-erlaubnis.html` it returns:
|
|
88
|
+
* ```
|
|
89
|
+
* {
|
|
90
|
+
* nl: '../nl/gecombineerde-vergunning-onbepaalde-duur.html',
|
|
91
|
+
* fr: '../fr/permis-unique-a-duree-illimitee.html',
|
|
92
|
+
* en: '../en/permanent-single-permit.html'
|
|
93
|
+
* }
|
|
94
|
+
* ```
|
|
95
|
+
*
|
|
96
|
+
* Returns an empty object when the page has no translatable lang prefix or
|
|
97
|
+
* when any slug cannot be found in the map.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} outputRelative - Relative output path, e.g. `de/page.html`.
|
|
100
|
+
* @param {string} lang - Current page locale, e.g. `'de'`.
|
|
101
|
+
* @param {object} slugMap - Parsed slug translation map (translations.json).
|
|
102
|
+
* @param {string} assetPath - Relative prefix back to the output root (e.g. `'../'`).
|
|
103
|
+
* @returns {Record<string, string>}
|
|
104
|
+
*/
|
|
105
|
+
function buildLangSwitcherUrls(outputRelative, lang, slugMap, assetPath) {
|
|
106
|
+
const normalized = outputRelative.split(path.sep).join('/');
|
|
107
|
+
const langPrefix = `${lang}/`;
|
|
108
|
+
|
|
109
|
+
if (!normalized.startsWith(langPrefix) || !slugMap?.[lang]) return {};
|
|
110
|
+
|
|
111
|
+
const slugs = normalized
|
|
112
|
+
.slice(langPrefix.length)
|
|
113
|
+
.replace(/\.html$/, '')
|
|
114
|
+
.split('/')
|
|
115
|
+
.filter(Boolean);
|
|
116
|
+
|
|
117
|
+
const indices = slugs.map(slug => slugMap[lang].indexOf(slug));
|
|
118
|
+
if (indices.some(i => i === -1)) return {};
|
|
119
|
+
|
|
120
|
+
const urls = {};
|
|
121
|
+
for (const targetLang of Object.keys(slugMap)) {
|
|
122
|
+
if (targetLang === lang || !slugMap[targetLang]) continue;
|
|
123
|
+
|
|
124
|
+
const translatedSlugs = indices.map(i => slugMap[targetLang][i]);
|
|
125
|
+
if (translatedSlugs.some(s => !s)) continue;
|
|
126
|
+
|
|
127
|
+
urls[targetLang] = `${assetPath}${targetLang}/${translatedSlugs.join('/')}.html`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return urls;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolves a Vite manifest entry by trying `preferredKeys` first, then
|
|
135
|
+
* falling back to a predicate scan of all entries. Returns `undefined` if
|
|
136
|
+
* nothing matches.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} manifest - Parsed `.vite/manifest.json`.
|
|
139
|
+
* @param {string[]} preferredKeys - Manifest keys to check in order of preference.
|
|
140
|
+
* @param {Function} fallbackMatcher - `(key, value) => boolean` used when no preferred key matches.
|
|
141
|
+
* @returns {object|undefined} The matched manifest entry, or `undefined`.
|
|
142
|
+
*/
|
|
143
|
+
function getViteManifestEntry(manifest, preferredKeys, fallbackMatcher) {
|
|
144
|
+
for (const key of preferredKeys) {
|
|
145
|
+
if (manifest[key]) {
|
|
146
|
+
return manifest[key];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return Object.entries(manifest).find(([key, value]) => fallbackMatcher(key, value))?.[1];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Reads the Vite build manifest and extracts the hashed JS and CSS asset
|
|
154
|
+
* paths for the main `scripts` entry point.
|
|
155
|
+
* Returns `{ js: '', css: '' }` when the manifest does not exist (dev mode).
|
|
156
|
+
*
|
|
157
|
+
* @returns {Promise<{ js: string, css: string }>}
|
|
158
|
+
*/
|
|
159
|
+
async function loadViteAssets() {
|
|
160
|
+
const manifestPath = path.join(outDir, '.vite', 'manifest.json');
|
|
161
|
+
if (!fs.existsSync(manifestPath)) {
|
|
162
|
+
return {};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, 'utf8'));
|
|
166
|
+
const entryBasename = path.basename(scriptsEntryKey);
|
|
167
|
+
const entryStem = path.basename(scriptsEntryKey, path.extname(scriptsEntryKey));
|
|
168
|
+
const scriptsEntry = getViteManifestEntry(
|
|
169
|
+
manifest,
|
|
170
|
+
[scriptsEntryKey, entryStem],
|
|
171
|
+
(key, value) => key.endsWith(entryBasename) || String(value?.file || '').includes(entryStem)
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
js: scriptsEntry?.file || '',
|
|
176
|
+
css: scriptsEntry?.css?.[0] || ''
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Registers custom Twig filters on the shared Twig instance:
|
|
182
|
+
*
|
|
183
|
+
* - `external_links` — Adds `target="_blank"`, `rel="noopener noreferrer"`,
|
|
184
|
+
* and a screen-reader label to external URLs and file download links.
|
|
185
|
+
* - `entity_encode` — HTML-entity-encodes `mailto:` and `tel:` link hrefs
|
|
186
|
+
* and their visible text to deter scraper harvesting.
|
|
187
|
+
*
|
|
188
|
+
* Safe to call multiple times; Twig silently overwrites existing filters.
|
|
189
|
+
*/
|
|
190
|
+
function registerTwigFilters() {
|
|
191
|
+
Twig.extendFilter('external_links', function(value, lang = 'fr') {
|
|
192
|
+
if (!value || typeof value !== 'string') return value;
|
|
193
|
+
|
|
194
|
+
const externalLabels = {
|
|
195
|
+
fr: 'Nouvelle fenêtre',
|
|
196
|
+
nl: 'Nieuw venster',
|
|
197
|
+
de: 'neues Fenster',
|
|
198
|
+
en: 'New window'
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const fileExtensions = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'pptx', 'zip'];
|
|
202
|
+
const label = externalLabels[lang] || externalLabels.fr;
|
|
203
|
+
|
|
204
|
+
return value.replace(/<a([^>]*)>(.*?)<\/a>/gis, (match, attrs, text) => {
|
|
205
|
+
const hrefMatch = attrs.match(/href\s*=\s*["']([^"']+)["']/i);
|
|
206
|
+
if (!hrefMatch) return match;
|
|
207
|
+
|
|
208
|
+
const href = hrefMatch[1];
|
|
209
|
+
if (href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) {
|
|
210
|
+
return match;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const ext = href.split('?')[0].split('.').pop().toLowerCase();
|
|
214
|
+
const isDownload = fileExtensions.includes(ext);
|
|
215
|
+
const isExternal = /^(https?:\/\/|www\.)/i.test(href);
|
|
216
|
+
|
|
217
|
+
if (!isExternal && !isDownload) return match;
|
|
218
|
+
|
|
219
|
+
if (!/target\s*=\s*["']_blank["']/.test(attrs)) {
|
|
220
|
+
attrs += ' target="_blank"';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!/rel\s*=/.test(attrs)) {
|
|
224
|
+
attrs += ' rel="noopener noreferrer"';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!text.includes('sr-only')) {
|
|
228
|
+
const labelText = isDownload ? `.${ext} — ${label}` : label;
|
|
229
|
+
text += `<span class="sr-only"> (${labelText})</span>`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return `<a${attrs}>${text}</a>`;
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
Twig.extendFilter('entity_encode', function(value) {
|
|
237
|
+
if (!value || typeof value !== 'string') return value;
|
|
238
|
+
|
|
239
|
+
const encode = (str) =>
|
|
240
|
+
str
|
|
241
|
+
.split('')
|
|
242
|
+
.map((char) => `&#${char.charCodeAt(0)};`)
|
|
243
|
+
.join('');
|
|
244
|
+
|
|
245
|
+
return value.replace(
|
|
246
|
+
/<a\s([^>]*)>(.*?)<\/a>/gis,
|
|
247
|
+
(match, attrs, text) => {
|
|
248
|
+
const hrefMatch = attrs.match(/href\s*=\s*["']((?:mailto|tel):[^"']+)["']/i);
|
|
249
|
+
if (!hrefMatch) return match;
|
|
250
|
+
|
|
251
|
+
const encodedAttrs = attrs.replace(
|
|
252
|
+
/(href\s*=\s*["'])(?:mailto|tel):[^"']+(?=["'])/i,
|
|
253
|
+
(_, prefix) => prefix + encode(hrefMatch[1])
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
return `<a ${encodedAttrs}>${encode(text)}</a>`;
|
|
257
|
+
}
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
for (const { name, fn } of filters) {
|
|
262
|
+
Twig.extendFilter(name, fn);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Renders every Twig page entry under `staticDir` to an HTML file in `outDir`.
|
|
268
|
+
*
|
|
269
|
+
* For each page the function:
|
|
270
|
+
* 1. Detects the locale from the file path and loads the matching translations.
|
|
271
|
+
* 2. Computes relative asset/template paths so the file works at any nesting depth.
|
|
272
|
+
* 3. Rewrites bare template references (e.g. `extends 'layout.twig'`) to
|
|
273
|
+
* paths relative to the page file, since Twig requires resolvable paths.
|
|
274
|
+
* 4. Compiles and renders the template, strips blank lines, and writes the result.
|
|
275
|
+
*
|
|
276
|
+
* Throws if `isBuild && useViteAssetsInBuild` is true but the Vite manifest
|
|
277
|
+
* assets could not be loaded (indicating the JS/CSS build step was skipped).
|
|
278
|
+
*
|
|
279
|
+
* @param {{ isBuild: boolean, useViteDevServer: boolean, viteDevBase: string }} context
|
|
280
|
+
* @returns {Promise<void>}
|
|
281
|
+
*/
|
|
282
|
+
async function renderTwigPages(context) {
|
|
283
|
+
const { isBuild, useViteDevServer, viteDevBase } = context;
|
|
284
|
+
registerTwigFilters();
|
|
285
|
+
|
|
286
|
+
const staticRoot = path.join(projectRoot, staticDir);
|
|
287
|
+
const templatesRoot = path.join(projectRoot, templatesDir);
|
|
288
|
+
const translationsRoot = path.join(projectRoot, translationsDir);
|
|
289
|
+
|
|
290
|
+
const globalVars = await loadJson(path.join(translationsRoot, 'global.json'));
|
|
291
|
+
const slugMap = slugMapPath ? await loadJson(path.join(projectRoot, slugMapPath)) : null;
|
|
292
|
+
const viteAssets = await loadViteAssets();
|
|
293
|
+
if (isBuild && useViteAssetsInBuild && (!viteAssets.js || !viteAssets.css)) {
|
|
294
|
+
throw new Error('Vite manifest assets are required for production Twig rendering.');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const allFiles = await walkFiles(staticRoot);
|
|
298
|
+
const pages = allFiles.filter((filePath) => {
|
|
299
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
300
|
+
const basename = path.basename(filePath);
|
|
301
|
+
return ext === '.twig' && !basename.startsWith('_');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
for (const pagePath of pages) {
|
|
305
|
+
const lang = detectLanguage(pagePath);
|
|
306
|
+
const translations = await loadJson(path.join(translationsRoot, `${lang}.json`));
|
|
307
|
+
const assetPath = calculateAssetPath(pagePath, staticRoot, outDir);
|
|
308
|
+
const outputRelative = path.relative(staticRoot, pagePath).replace(/\.twig$/, '.html');
|
|
309
|
+
const outputPath = path.join(outDir, outputRelative);
|
|
310
|
+
|
|
311
|
+
const langSwitcherUrls = slugMap
|
|
312
|
+
? buildLangSwitcherUrls(outputRelative, lang, slugMap, assetPath)
|
|
313
|
+
: {};
|
|
314
|
+
|
|
315
|
+
const data = {
|
|
316
|
+
...globalVars,
|
|
317
|
+
...translations,
|
|
318
|
+
locale: lang,
|
|
319
|
+
assetPath,
|
|
320
|
+
langSwitcherUrls,
|
|
321
|
+
isProduction: isBuild,
|
|
322
|
+
useViteDevServer,
|
|
323
|
+
viteDevBase,
|
|
324
|
+
useViteAssets: isBuild && useViteAssetsInBuild,
|
|
325
|
+
viteAssets
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const pageDir = path.dirname(pagePath);
|
|
329
|
+
const relativeTplsPath = path.relative(pageDir, templatesRoot).split(path.sep).join('/');
|
|
330
|
+
const templateContent = (await fs.promises.readFile(pagePath, 'utf8')).replace(
|
|
331
|
+
/(\{%\s*(?:extends|include|embed|import|from)\s+['"])([^'"]+?\.twig)(['"])/g,
|
|
332
|
+
(fullMatch, prefix, targetPath, suffix) => {
|
|
333
|
+
if (targetPath.startsWith('.') || targetPath.startsWith('/')) {
|
|
334
|
+
return fullMatch;
|
|
335
|
+
}
|
|
336
|
+
return `${prefix}${relativeTplsPath}/${targetPath}${suffix}`;
|
|
337
|
+
}
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const compiled = Twig.twig({
|
|
341
|
+
data: templateContent,
|
|
342
|
+
path: pagePath,
|
|
343
|
+
base: templatesRoot,
|
|
344
|
+
rethrow: true
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const html = compiled
|
|
348
|
+
.render(data)
|
|
349
|
+
.split('\n')
|
|
350
|
+
.filter((line) => line.trim() !== '')
|
|
351
|
+
.join('\n');
|
|
352
|
+
|
|
353
|
+
await ensureDir(path.dirname(outputPath));
|
|
354
|
+
await fs.promises.writeFile(outputPath, `${html}\n`, 'utf8');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
renderTwigPages
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export { createTwigPagesTask };
|