@riebeckite/plugin-permalink 0.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 +201 -0
- package/README.md +965 -0
- package/README_ja.md +981 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +254 -0
- package/package.json +52 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ContentLocationInput, type PostFrontmatter } from "@riebeckite/core";
|
|
2
|
+
export type PermalinkIdStrategy = "frontmatter" | "hash" | "frontmatter-or-hash";
|
|
3
|
+
export type PermalinkPathMode = "flat" | "preserve" | "append";
|
|
4
|
+
export type RedirectStatus = 301 | 302 | 307 | 308;
|
|
5
|
+
type ResolverContent = Pick<ContentLocationInput, "slug" | "path"> & {
|
|
6
|
+
frontmatter: PostFrontmatter;
|
|
7
|
+
};
|
|
8
|
+
export type PermalinkOptions = {
|
|
9
|
+
frontmatter?: string;
|
|
10
|
+
id?: {
|
|
11
|
+
strategy?: PermalinkIdStrategy;
|
|
12
|
+
length?: number;
|
|
13
|
+
};
|
|
14
|
+
path?: {
|
|
15
|
+
mode?: PermalinkPathMode;
|
|
16
|
+
prefix?: string;
|
|
17
|
+
trailingSlash?: boolean;
|
|
18
|
+
};
|
|
19
|
+
index?: {
|
|
20
|
+
collapse?: boolean;
|
|
21
|
+
};
|
|
22
|
+
override?: {
|
|
23
|
+
frontmatter?: string;
|
|
24
|
+
};
|
|
25
|
+
redirects?: {
|
|
26
|
+
frontmatter?: string;
|
|
27
|
+
status?: RedirectStatus;
|
|
28
|
+
};
|
|
29
|
+
resolveId?: (content: ResolverContent) => string;
|
|
30
|
+
resolvePath?: (input: {
|
|
31
|
+
content: ResolverContent;
|
|
32
|
+
id: string;
|
|
33
|
+
}) => string;
|
|
34
|
+
};
|
|
35
|
+
export declare function permalink(options?: PermalinkOptions): import("@riebeckite/core").RiebeckitePlugin<unknown>;
|
|
36
|
+
export declare const permalinkPlugin: typeof permalink;
|
|
37
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// index.ts
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
definePlugin
|
|
5
|
+
} from "@riebeckite/core";
|
|
6
|
+
import { parse } from "yaml";
|
|
7
|
+
function permalink(options = {}) {
|
|
8
|
+
const resolved = resolveOptions(options);
|
|
9
|
+
return definePlugin({
|
|
10
|
+
name: "permalink",
|
|
11
|
+
resolveContentLocations: ({ entries }) => {
|
|
12
|
+
const contents = entries.map((entry) => ({
|
|
13
|
+
...entry,
|
|
14
|
+
frontmatter: parseFrontmatter(entry)
|
|
15
|
+
}));
|
|
16
|
+
const locations = contents.map(
|
|
17
|
+
(content) => resolveLocation(content, options, resolved)
|
|
18
|
+
);
|
|
19
|
+
assertNoCollisions(locations, contents);
|
|
20
|
+
return locations;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
var permalinkPlugin = permalink;
|
|
25
|
+
function resolveLocation(content, options, resolved) {
|
|
26
|
+
const override = readOptionalString(
|
|
27
|
+
content.frontmatter,
|
|
28
|
+
resolved.overrideField
|
|
29
|
+
);
|
|
30
|
+
const explicitId = readOptionalString(
|
|
31
|
+
content.frontmatter,
|
|
32
|
+
resolved.frontmatter
|
|
33
|
+
);
|
|
34
|
+
const id = override ? explicitId === null ? null : validateId(explicitId, content.path) : options.resolveId ? validateId(options.resolveId(content), content.path) : resolveId(content, resolved);
|
|
35
|
+
const permalink2 = override ? normalizePath(override, resolved.trailingSlash) : normalizePath(
|
|
36
|
+
options.resolvePath ? options.resolvePath({ content, id: id ?? "" }) : composePath(content, id ?? "", resolved),
|
|
37
|
+
resolved.trailingSlash
|
|
38
|
+
);
|
|
39
|
+
const redirectValues = content.frontmatter[resolved.redirectsField];
|
|
40
|
+
const redirects = readRedirects(redirectValues, content.path).map((path) => ({
|
|
41
|
+
path: normalizePath(path, resolved.trailingSlash),
|
|
42
|
+
status: resolved.redirectStatus
|
|
43
|
+
}));
|
|
44
|
+
return {
|
|
45
|
+
slug: content.slug,
|
|
46
|
+
permalink: permalink2,
|
|
47
|
+
redirects,
|
|
48
|
+
metadata: id ? {
|
|
49
|
+
id,
|
|
50
|
+
idSource: idSource(content, options, resolved, override !== null)
|
|
51
|
+
} : void 0
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function idSource(content, options, resolved, hasOverride) {
|
|
55
|
+
if (hasOverride) return "frontmatter";
|
|
56
|
+
if (options.resolveId) return "custom";
|
|
57
|
+
if (resolved.strategy === "hash" || resolved.strategy === "frontmatter-or-hash" && !hasFrontmatterId(content.frontmatter, resolved.frontmatter)) {
|
|
58
|
+
return "derived";
|
|
59
|
+
}
|
|
60
|
+
return "frontmatter";
|
|
61
|
+
}
|
|
62
|
+
function resolveId(content, options) {
|
|
63
|
+
const value = readOptionalString(content.frontmatter, options.frontmatter);
|
|
64
|
+
if (options.strategy === "frontmatter") {
|
|
65
|
+
if (!value) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Permalink ID is required in frontmatter field "${options.frontmatter}": ${content.path}`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
return validateId(value, content.path);
|
|
71
|
+
}
|
|
72
|
+
if (options.strategy === "frontmatter-or-hash" && value) {
|
|
73
|
+
return validateId(value, content.path);
|
|
74
|
+
}
|
|
75
|
+
return createPathId(content.path, options.hashLength);
|
|
76
|
+
}
|
|
77
|
+
function composePath(content, id, options) {
|
|
78
|
+
if (options.mode === "flat") return joinPath(options.prefix, id);
|
|
79
|
+
const segments = content.slug.split("/");
|
|
80
|
+
const fileName = segments.pop() ?? content.slug;
|
|
81
|
+
if (options.mode === "preserve") {
|
|
82
|
+
const directories = fileName === "index" && !options.collapseIndex ? [...segments, fileName] : segments;
|
|
83
|
+
return joinPath(options.prefix, ...directories, id);
|
|
84
|
+
}
|
|
85
|
+
const appendSegments = fileName === "index" && options.collapseIndex ? segments : [...segments, fileName];
|
|
86
|
+
return joinPath(options.prefix, ...appendSegments, id);
|
|
87
|
+
}
|
|
88
|
+
function createPathId(path, length) {
|
|
89
|
+
const normalized = path.replaceAll("\\", "/").replace(/^\/+/, "").normalize("NFC");
|
|
90
|
+
return createHash("sha256").update(normalized, "utf8").digest("base64url").slice(0, length);
|
|
91
|
+
}
|
|
92
|
+
function parseFrontmatter(entry) {
|
|
93
|
+
const match = entry.markdown.match(
|
|
94
|
+
/^(?:\uFEFF)?---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/
|
|
95
|
+
);
|
|
96
|
+
if (!match) return {};
|
|
97
|
+
const value = parse(match[1] ?? "");
|
|
98
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
99
|
+
throw new Error(`Invalid frontmatter object: ${entry.path}`);
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
function resolveOptions(options) {
|
|
104
|
+
const frontmatter = options.frontmatter ?? "id";
|
|
105
|
+
const strategy = options.id?.strategy ?? "frontmatter-or-hash";
|
|
106
|
+
const hashLength = options.id?.length ?? 12;
|
|
107
|
+
if (!Number.isInteger(hashLength) || hashLength < 6 || hashLength > 43) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
"Permalink hash ID length must be an integer from 6 through 43."
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const mode = options.path?.mode ?? "flat";
|
|
113
|
+
if (!["flat", "preserve", "append"].includes(mode)) {
|
|
114
|
+
throw new Error(`Invalid permalink path mode: ${mode}`);
|
|
115
|
+
}
|
|
116
|
+
const redirectStatus = options.redirects?.status ?? 308;
|
|
117
|
+
if (![301, 302, 307, 308].includes(redirectStatus)) {
|
|
118
|
+
throw new Error(`Invalid permalink redirect status: ${redirectStatus}`);
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
frontmatter: validateFieldName(frontmatter, "ID"),
|
|
122
|
+
strategy,
|
|
123
|
+
hashLength,
|
|
124
|
+
mode,
|
|
125
|
+
prefix: normalizePrefix(options.path?.prefix ?? "/n"),
|
|
126
|
+
trailingSlash: options.path?.trailingSlash ?? false,
|
|
127
|
+
collapseIndex: options.index?.collapse ?? true,
|
|
128
|
+
overrideField: validateFieldName(
|
|
129
|
+
options.override?.frontmatter ?? "permalink",
|
|
130
|
+
"override"
|
|
131
|
+
),
|
|
132
|
+
redirectsField: validateFieldName(
|
|
133
|
+
options.redirects?.frontmatter ?? "redirect_from",
|
|
134
|
+
"redirect"
|
|
135
|
+
),
|
|
136
|
+
redirectStatus
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function assertNoCollisions(locations, contents) {
|
|
140
|
+
const bySlug = new Map(contents.map((content) => [content.slug, content]));
|
|
141
|
+
const ids = /* @__PURE__ */ new Map();
|
|
142
|
+
const paths = /* @__PURE__ */ new Map();
|
|
143
|
+
for (const location of locations) {
|
|
144
|
+
const id = location.metadata?.id;
|
|
145
|
+
if (id) assertUnique(ids, id, location, bySlug, "Permalink ID collision");
|
|
146
|
+
assertPathUnique(paths, location.permalink, location, "canonical", bySlug);
|
|
147
|
+
for (const redirect of location.redirects ?? []) {
|
|
148
|
+
assertPathUnique(paths, redirect.path, location, "redirect", bySlug);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function assertUnique(values, key, next, contents, heading) {
|
|
153
|
+
const previous = values.get(key);
|
|
154
|
+
if (previous)
|
|
155
|
+
throw collisionError(heading, key, previous, next, contents, "ID", "ID");
|
|
156
|
+
values.set(key, next);
|
|
157
|
+
}
|
|
158
|
+
function assertPathUnique(values, key, next, kind, contents) {
|
|
159
|
+
const previous = values.get(key);
|
|
160
|
+
if (previous)
|
|
161
|
+
throw collisionError(
|
|
162
|
+
"Permalink URL collision",
|
|
163
|
+
key,
|
|
164
|
+
previous.location,
|
|
165
|
+
next,
|
|
166
|
+
contents,
|
|
167
|
+
previous.kind,
|
|
168
|
+
kind
|
|
169
|
+
);
|
|
170
|
+
values.set(key, { location: next, kind });
|
|
171
|
+
}
|
|
172
|
+
function collisionError(heading, key, first, second, contents, firstKind, secondKind) {
|
|
173
|
+
return new Error(
|
|
174
|
+
`${heading}: ${key}
|
|
175
|
+
|
|
176
|
+
- ${contents.get(first.slug)?.path ?? first.slug} (${firstKind})
|
|
177
|
+
- ${contents.get(second.slug)?.path ?? second.slug} (${secondKind})`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
function readOptionalString(frontmatter, field) {
|
|
181
|
+
const value = frontmatter[field];
|
|
182
|
+
if (value === void 0 || value === null) return null;
|
|
183
|
+
if (typeof value !== "string")
|
|
184
|
+
throw new Error(`Permalink frontmatter field "${field}" must be a string.`);
|
|
185
|
+
return value.trim() || null;
|
|
186
|
+
}
|
|
187
|
+
function readRedirects(value, path) {
|
|
188
|
+
if (value === void 0 || value === null) return [];
|
|
189
|
+
if (typeof value === "string") return [value];
|
|
190
|
+
if (Array.isArray(value) && value.every((entry) => typeof entry === "string"))
|
|
191
|
+
return value;
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Permalink redirect field must be a string or string array: ${path}`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
function hasFrontmatterId(frontmatter, field) {
|
|
197
|
+
return typeof frontmatter[field] === "string" && frontmatter[field].trim().length > 0;
|
|
198
|
+
}
|
|
199
|
+
function validateId(value, path) {
|
|
200
|
+
const id = value.trim();
|
|
201
|
+
const decoded = hasMalformedPercentEncoding(id) ? null : decodeURIComponent(id);
|
|
202
|
+
if (!id || !decoded || decoded === "." || decoded === ".." || /[\\/#?]/.test(decoded) || /\s/.test(decoded)) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`Invalid permalink ID "${value}" in ${path}. IDs must be a non-empty single path segment.`
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return id;
|
|
208
|
+
}
|
|
209
|
+
function normalizePrefix(value) {
|
|
210
|
+
if (!value.trim() || value.trim() === "/") return "";
|
|
211
|
+
return normalizePath(value, false);
|
|
212
|
+
}
|
|
213
|
+
function normalizePath(value, trailingSlash) {
|
|
214
|
+
const raw = value.trim();
|
|
215
|
+
if (raw.length === 0 || !raw.startsWith("/") || raw.includes("?") || raw.includes("#") || raw.includes("\\") || raw.includes("//")) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Invalid permalink path: "${value}". It must be a site-local absolute path.`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const segments = raw.split("/").filter(Boolean).map((segment) => {
|
|
221
|
+
if (segment === "." || segment === ".." || hasMalformedPercentEncoding(segment)) {
|
|
222
|
+
throw new Error(`Invalid permalink path: "${value}".`);
|
|
223
|
+
}
|
|
224
|
+
return encodeURIComponent(decodeURIComponent(segment));
|
|
225
|
+
});
|
|
226
|
+
const path = `/${segments.join("/")}`;
|
|
227
|
+
if (path === "/") return "/";
|
|
228
|
+
return trailingSlash ? `${path}/` : path;
|
|
229
|
+
}
|
|
230
|
+
function joinPath(prefix, ...segments) {
|
|
231
|
+
return [
|
|
232
|
+
prefix,
|
|
233
|
+
...segments.map(
|
|
234
|
+
(segment) => encodeURIComponent(decodeURIComponent(segment))
|
|
235
|
+
)
|
|
236
|
+
].filter(Boolean).join("/").replace(/^([^/])/, "/$1");
|
|
237
|
+
}
|
|
238
|
+
function hasMalformedPercentEncoding(value) {
|
|
239
|
+
try {
|
|
240
|
+
decodeURIComponent(value);
|
|
241
|
+
return false;
|
|
242
|
+
} catch {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function validateFieldName(value, label) {
|
|
247
|
+
if (!value.trim() || /[.[\]]/.test(value))
|
|
248
|
+
throw new Error(`Permalink ${label} frontmatter field must be top-level.`);
|
|
249
|
+
return value;
|
|
250
|
+
}
|
|
251
|
+
export {
|
|
252
|
+
permalink,
|
|
253
|
+
permalinkPlugin
|
|
254
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@riebeckite/plugin-permalink",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"source": "./index.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"yaml": "^2.9.0",
|
|
18
|
+
"@riebeckite/core": "0.0.1"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"README.md",
|
|
26
|
+
"README_ja.md",
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/Rerurate514/riebeckite.git",
|
|
32
|
+
"directory": "packages/plugins/permalink"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/Rerurate514/riebeckite#readme",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/Rerurate514/riebeckite/issues"
|
|
37
|
+
},
|
|
38
|
+
"description": "Stable, configurable content permalinks for Riebeckite.",
|
|
39
|
+
"keywords": [
|
|
40
|
+
"riebeckite",
|
|
41
|
+
"plugin",
|
|
42
|
+
"permalinks",
|
|
43
|
+
"redirects"
|
|
44
|
+
],
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
47
|
+
},
|
|
48
|
+
"sideEffects": false,
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "node ../../../scripts/build_package.mjs"
|
|
51
|
+
}
|
|
52
|
+
}
|