@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.
@@ -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
+ }