@flyewi/eleventy-plugin-redirects 0.1.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/LICENSE +21 -0
- package/README.md +74 -0
- package/lib/formats.js +49 -0
- package/lib/normalize.js +43 -0
- package/lib/redirects.js +74 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 flyewi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, 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,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# eleventy-plugin-redirects
|
|
2
|
+
|
|
3
|
+
[](https://www.buymeacoffee.com/flyewiy)
|
|
4
|
+
|
|
5
|
+
Eleventy plugin that turns a single, centrally maintained redirects config into the formats your host actually understands: a Netlify `_redirects` file, an Apache `.htaccess`, and/or static HTML fallback pages for hosts that support neither.
|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
URLs change. Instead of hand-editing `_redirects` (or forgetting to), keep redirects in your Eleventy config next to the rest of your site and let this plugin regenerate the host-specific file on every build.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
npm install @flyewi/eleventy-plugin-redirects
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
const redirectsPlugin = require("@flyewi/eleventy-plugin-redirects");
|
|
19
|
+
|
|
20
|
+
module.exports = function (eleventyConfig) {
|
|
21
|
+
eleventyConfig.addPlugin(redirectsPlugin, {
|
|
22
|
+
redirects: {
|
|
23
|
+
"/old-path/": "/new-path/",
|
|
24
|
+
"/legacy-page/": { to: "/new-page/", status: 302 },
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
By default this writes an `_redirects` file into your output directory after every build.
|
|
31
|
+
|
|
32
|
+
## Options
|
|
33
|
+
|
|
34
|
+
| Option | Default | Description |
|
|
35
|
+
| --- | --- | --- |
|
|
36
|
+
| `redirects` | `{}` | Object map (`{ from: to }` or `{ from: { to, status } }`) or array of `{ from, to, status }`. Use the array form if the same `from` needs multiple rules. |
|
|
37
|
+
| `defaultStatus` | `301` | Status code used when an entry doesn't specify one. |
|
|
38
|
+
| `netlify` | `true` | Write a Netlify `_redirects` file. |
|
|
39
|
+
| `netlifyFile` | `"_redirects"` | Filename for the Netlify output, relative to the output directory. |
|
|
40
|
+
| `apache` | `false` | Write an Apache `.htaccess` with `Redirect` directives. |
|
|
41
|
+
| `apacheFile` | `".htaccess"` | Filename for the Apache output. |
|
|
42
|
+
| `html` | `false` | Also write a static HTML fallback page at each `from` URL (meta-refresh + `rel=canonical`), for hosts (e.g. GitHub Pages) that can't act on `_redirects`/`.htaccess`. |
|
|
43
|
+
| `log` | `true` | Log a one-line summary after each build. |
|
|
44
|
+
|
|
45
|
+
## Netlify output
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
/old-path/ /new-path/ 301
|
|
49
|
+
/legacy-page/ /new-page/ 302
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
If your output directory already contains an `_redirects` file (e.g. copied through as a static passthrough for rules this plugin doesn't manage, like an SPA catch-all), the generated rules are **prepended** to it — Netlify uses the first matching rule, so your specific redirects still take priority over any broader existing rule.
|
|
53
|
+
|
|
54
|
+
## Apache output
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
Redirect 301 /old-path/ /new-path/
|
|
58
|
+
Redirect 302 /legacy-page/ /new-page/
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## HTML fallback pages
|
|
62
|
+
|
|
63
|
+
With `html: true`, each `from` path also gets a real HTML file at that URL (e.g. `/old-path/` → `old-path/index.html` in the output directory) containing:
|
|
64
|
+
|
|
65
|
+
```html
|
|
66
|
+
<meta http-equiv="refresh" content="0; url=/new-path/">
|
|
67
|
+
<link rel="canonical" href="/new-path/">
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This is a static fallback for platforms without server-side redirect support — a real `_redirects`/`.htaccess` (or your host/CDN's redirect rules) is always the better option when available, since the HTML approach is a moment slower and depends on the browser executing the refresh.
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
package/lib/formats.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const path = require("node:path");
|
|
2
|
+
|
|
3
|
+
function toNetlify(list) {
|
|
4
|
+
return list.map(({ from, to, status }) => `${from} ${to} ${status}`).join("\n") + "\n";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function toApache(list) {
|
|
8
|
+
return list.map(({ from, to, status }) => `Redirect ${status} ${from} ${to}`).join("\n") + "\n";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function escapeAttr(str) {
|
|
12
|
+
return String(str)
|
|
13
|
+
.replace(/&/g, "&")
|
|
14
|
+
.replace(/"/g, """)
|
|
15
|
+
.replace(/</g, "<")
|
|
16
|
+
.replace(/>/g, ">");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A static fallback for hosts that can't act on _redirects/.htaccess (e.g.
|
|
20
|
+
// GitHub Pages): an actual HTML file at the old URL that both redirects
|
|
21
|
+
// browsers instantly and points crawlers at the new canonical URL.
|
|
22
|
+
function toHtmlPage(to) {
|
|
23
|
+
const target = escapeAttr(to);
|
|
24
|
+
return `<!doctype html>
|
|
25
|
+
<html lang="en">
|
|
26
|
+
<head>
|
|
27
|
+
<meta charset="utf-8">
|
|
28
|
+
<title>Redirecting…</title>
|
|
29
|
+
<meta http-equiv="refresh" content="0; url=${target}">
|
|
30
|
+
<link rel="canonical" href="${target}">
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<p>This page has moved to <a href="${target}">${target}</a>.</p>
|
|
34
|
+
</body>
|
|
35
|
+
</html>
|
|
36
|
+
`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Maps a redirect's `from` path to the file Eleventy's pretty-URL convention
|
|
40
|
+
// would expect it at, e.g. "/old-path" -> "old-path/index.html", so an HTML
|
|
41
|
+
// fallback page lands exactly where the old URL is requested from.
|
|
42
|
+
function toOutputPath(from) {
|
|
43
|
+
const clean = from.replace(/^\/+/, "");
|
|
44
|
+
if (clean === "" || clean.endsWith("/")) return path.join(clean, "index.html");
|
|
45
|
+
if (path.extname(clean)) return clean;
|
|
46
|
+
return path.join(clean, "index.html");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { toNetlify, toApache, toHtmlPage, toOutputPath };
|
package/lib/normalize.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
function normalizePath(p) {
|
|
2
|
+
return p.startsWith("/") ? p : `/${p}`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
// Accepts redirects as either an object map ({ "/old/": "/new/" } or
|
|
6
|
+
// { "/old/": { to: "/new/", status: 302 } }) or an array of
|
|
7
|
+
// { from, to, status } entries, and returns a single normalized array.
|
|
8
|
+
// Object form is more convenient for hand-written config; array form allows
|
|
9
|
+
// duplicate `from` paths (e.g. generated redirects) that an object key can't.
|
|
10
|
+
function normalizeRedirects(redirects, defaultStatus) {
|
|
11
|
+
const list = [];
|
|
12
|
+
|
|
13
|
+
const addEntry = (from, to, status) => {
|
|
14
|
+
if (!from || !to) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`eleventy-plugin-redirects: invalid redirect entry (from: "${from}", to: "${to}")`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
list.push({
|
|
20
|
+
from: normalizePath(from),
|
|
21
|
+
to,
|
|
22
|
+
status: status || defaultStatus,
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (Array.isArray(redirects)) {
|
|
27
|
+
for (const entry of redirects) {
|
|
28
|
+
addEntry(entry.from, entry.to, entry.status);
|
|
29
|
+
}
|
|
30
|
+
} else if (redirects && typeof redirects === "object") {
|
|
31
|
+
for (const [from, value] of Object.entries(redirects)) {
|
|
32
|
+
if (typeof value === "string") {
|
|
33
|
+
addEntry(from, value);
|
|
34
|
+
} else if (value && typeof value === "object") {
|
|
35
|
+
addEntry(from, value.to, value.status);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return list;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { normalizeRedirects, normalizePath };
|
package/lib/redirects.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const path = require("node:path");
|
|
3
|
+
|
|
4
|
+
const { normalizeRedirects } = require("./normalize");
|
|
5
|
+
const { toNetlify, toApache, toHtmlPage, toOutputPath } = require("./formats");
|
|
6
|
+
|
|
7
|
+
const DEFAULTS = {
|
|
8
|
+
redirects: {},
|
|
9
|
+
defaultStatus: 301,
|
|
10
|
+
netlify: true,
|
|
11
|
+
apache: false,
|
|
12
|
+
html: false,
|
|
13
|
+
netlifyFile: "_redirects",
|
|
14
|
+
apacheFile: ".htaccess",
|
|
15
|
+
log: true,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function readExisting(file) {
|
|
19
|
+
if (!fs.existsSync(file)) return "";
|
|
20
|
+
const content = fs.readFileSync(file, "utf8");
|
|
21
|
+
return content.endsWith("\n") ? content : `${content}\n`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = function redirectsPlugin(eleventyConfig, userOptions = {}) {
|
|
25
|
+
const options = { ...DEFAULTS, ...userOptions };
|
|
26
|
+
const list = normalizeRedirects(options.redirects, options.defaultStatus);
|
|
27
|
+
|
|
28
|
+
function log(...args) {
|
|
29
|
+
if (options.log) console.log("[redirects]", ...args);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
eleventyConfig.on("eleventy.after", async ({ dir }) => {
|
|
33
|
+
if (list.length === 0) return;
|
|
34
|
+
|
|
35
|
+
const outputDir = dir?.output || "_site";
|
|
36
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
37
|
+
|
|
38
|
+
if (options.netlify) {
|
|
39
|
+
const netlifyPath = path.join(outputDir, options.netlifyFile);
|
|
40
|
+
// Our rules go first: in Netlify's _redirects format the first matching
|
|
41
|
+
// rule wins, so our specific redirects must take priority over any
|
|
42
|
+
// broader rule (e.g. a SPA catch-all) already emitted into this file
|
|
43
|
+
// by a static passthrough copy.
|
|
44
|
+
const existing = readExisting(netlifyPath);
|
|
45
|
+
fs.writeFileSync(netlifyPath, toNetlify(list) + existing);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (options.apache) {
|
|
49
|
+
const apachePath = path.join(outputDir, options.apacheFile);
|
|
50
|
+
// Preserve (and prepend ahead of) any existing .htaccess content, e.g.
|
|
51
|
+
// hand-maintained directives (ErrorDocument, mod_deflate, mod_expires)
|
|
52
|
+
// brought in via a static passthrough copy — this plugin only owns the
|
|
53
|
+
// Redirect lines, not the whole file.
|
|
54
|
+
const existing = readExisting(apachePath);
|
|
55
|
+
fs.writeFileSync(apachePath, toApache(list) + existing);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (options.html) {
|
|
59
|
+
for (const { from, to } of list) {
|
|
60
|
+
const outPath = path.join(outputDir, toOutputPath(from));
|
|
61
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
62
|
+
fs.writeFileSync(outPath, toHtmlPage(to));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
log(`wrote ${list.length} redirect(s)`);
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
module.exports.normalizeRedirects = normalizeRedirects;
|
|
71
|
+
module.exports.toNetlify = toNetlify;
|
|
72
|
+
module.exports.toApache = toApache;
|
|
73
|
+
module.exports.toHtmlPage = toHtmlPage;
|
|
74
|
+
module.exports.toOutputPath = toOutputPath;
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@flyewi/eleventy-plugin-redirects",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Eleventy plugin that generates Netlify _redirects, Apache .htaccess, and static HTML fallback pages from a single redirects config.",
|
|
5
|
+
"main": "lib/redirects.js",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --test test/*.test.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"eleventy",
|
|
12
|
+
"eleventy-plugin",
|
|
13
|
+
"11ty",
|
|
14
|
+
"redirects",
|
|
15
|
+
"netlify",
|
|
16
|
+
"htaccess",
|
|
17
|
+
"seo"
|
|
18
|
+
],
|
|
19
|
+
"author": "flyewi",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/flyewi/eleventy-plugin-redirects.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/flyewi/eleventy-plugin-redirects#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/flyewi/eleventy-plugin-redirects/issues"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@11ty/eleventy": "^2.0.0 || ^3.0.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@11ty/eleventy": "^3.0.0"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"lib"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|