@dirwell/core 0.0.1-alpha.1 → 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/README.md +27 -2
- package/dist/client.d.mts +1 -1
- package/dist/client.mjs +1 -0
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +23 -14
- package/dist/index.mjs +469 -98
- package/dist/index.mjs.map +1 -1
- package/dist/{listing-CnkSglVT.d.mts → listing-B_6TbWuz.d.mts} +40 -3
- package/package.json +7 -2
package/dist/index.mjs
CHANGED
|
@@ -1,9 +1,297 @@
|
|
|
1
1
|
import { t as copyJsonObject } from "./json-Bvj6xQLq.mjs";
|
|
2
2
|
import { cp, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { createHash } from "node:crypto";
|
|
5
4
|
import picomatch from "picomatch";
|
|
5
|
+
import ignore from "ignore";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
7
|
import { constants } from "node:fs";
|
|
8
|
+
//#region src/filters.ts
|
|
9
|
+
/** Resolve configuration paths once, before a host changes working directories. */
|
|
10
|
+
function resolveIgnoreFiles(value = [], cwd = process.cwd()) {
|
|
11
|
+
if (!Array.isArray(value)) throw new TypeError("ignoreFiles must be an array");
|
|
12
|
+
return value.map((input) => {
|
|
13
|
+
const file = typeof input === "string" ? { path: input } : input;
|
|
14
|
+
if (typeof file !== "object" || file === null || Array.isArray(file)) throw new TypeError("ignoreFiles entries must be paths or objects");
|
|
15
|
+
for (const key of Object.keys(file)) if (![
|
|
16
|
+
"path",
|
|
17
|
+
"baseDir",
|
|
18
|
+
"optional"
|
|
19
|
+
].includes(key)) throw new TypeError(`Unsupported ignoreFiles key "${key}"`);
|
|
20
|
+
for (const key of ["path", "baseDir"]) if ((key === "path" || file[key] !== void 0) && (typeof file[key] !== "string" || file[key].length === 0)) throw new TypeError(`ignoreFiles.${key} must be a non-empty string`);
|
|
21
|
+
if (file.optional !== void 0 && typeof file.optional !== "boolean") throw new TypeError("ignoreFiles.optional must be a boolean");
|
|
22
|
+
const filename = path.resolve(cwd, file.path);
|
|
23
|
+
return {
|
|
24
|
+
path: filename,
|
|
25
|
+
baseDir: path.resolve(cwd, file.baseDir ?? path.dirname(filename)),
|
|
26
|
+
optional: file.optional ?? false
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async function createIgnoreMatcher(files) {
|
|
31
|
+
const rules = [];
|
|
32
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
33
|
+
let hasRules = false;
|
|
34
|
+
for (const file of resolveIgnoreFiles(files)) {
|
|
35
|
+
let contents;
|
|
36
|
+
try {
|
|
37
|
+
contents = await readFile(file.path, "utf8");
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (file.optional && error instanceof Error && "code" in error && error.code === "ENOENT") continue;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
const base = await realpath(file.baseDir);
|
|
43
|
+
if (contents.split(/\r?\n/).some((line) => line.trim() !== "" && !line.startsWith("#"))) hasRules = true;
|
|
44
|
+
let matcher = scopes.get(base);
|
|
45
|
+
if (matcher === void 0) {
|
|
46
|
+
matcher = ignore();
|
|
47
|
+
scopes.set(base, matcher);
|
|
48
|
+
rules.push({
|
|
49
|
+
base,
|
|
50
|
+
matcher
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
matcher.add(contents);
|
|
54
|
+
}
|
|
55
|
+
rules.sort((left, right) => left.base.split(path.sep).length - right.base.split(path.sep).length);
|
|
56
|
+
const matches = (filename, directory) => {
|
|
57
|
+
let ignored = false;
|
|
58
|
+
for (const rule of rules) {
|
|
59
|
+
if (!isWithin(rule.base, filename)) continue;
|
|
60
|
+
const relative = path.relative(rule.base, filename).split(path.sep).join("/");
|
|
61
|
+
if (relative === "") continue;
|
|
62
|
+
const result = rule.matcher.test(relative + (directory ? "/" : ""));
|
|
63
|
+
if (result.ignored) ignored = true;
|
|
64
|
+
else if (result.unignored) ignored = false;
|
|
65
|
+
}
|
|
66
|
+
return ignored;
|
|
67
|
+
};
|
|
68
|
+
return Object.assign(matches, { hasRules });
|
|
69
|
+
}
|
|
70
|
+
function normalizePathPatterns(value, name) {
|
|
71
|
+
if (value === void 0) return [];
|
|
72
|
+
const patterns = typeof value === "string" ? [value] : value;
|
|
73
|
+
if (!Array.isArray(patterns) || patterns.some((pattern) => typeof pattern !== "string" || pattern.length === 0 || pattern.startsWith("/") || /^[A-Za-z]:/.test(pattern) || pattern.startsWith("!") || pattern.includes("\\") || pattern.split("/").some((segment) => segment === "." || segment === ".."))) throw new TypeError(`${name} must contain root-relative glob patterns using forward slashes`);
|
|
74
|
+
return patterns;
|
|
75
|
+
}
|
|
76
|
+
function isWithin(parent, candidate) {
|
|
77
|
+
const relative = path.relative(parent, candidate);
|
|
78
|
+
return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
79
|
+
}
|
|
80
|
+
/** Select physical source paths; parent directories remain when a descendant matches. */
|
|
81
|
+
async function selectSourcePaths(sourceDir, outputDir, include, exclude, ignoredPaths = [], ignoreFiles = [], preparedIgnore) {
|
|
82
|
+
const isIgnored = preparedIgnore ?? await createIgnoreMatcher(ignoreFiles);
|
|
83
|
+
const canonicalRoot = await realpath(sourceDir);
|
|
84
|
+
const ignored = ignoredPaths.map((value) => path.resolve(value));
|
|
85
|
+
const included = normalizePathPatterns(include, "include").map((pattern) => picomatch(pattern, {
|
|
86
|
+
dot: true,
|
|
87
|
+
nonegate: true
|
|
88
|
+
}));
|
|
89
|
+
const excluded = normalizePathPatterns(exclude, "exclude").map((pattern) => picomatch(pattern, {
|
|
90
|
+
dot: true,
|
|
91
|
+
nonegate: true
|
|
92
|
+
}));
|
|
93
|
+
const selected = /* @__PURE__ */ new Set([""]);
|
|
94
|
+
const symlinks = /* @__PURE__ */ new Map();
|
|
95
|
+
async function visit(directory, relativeDir, inheritedInclude) {
|
|
96
|
+
let hasSelected = false;
|
|
97
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
98
|
+
const absolutePath = path.join(directory, entry.name);
|
|
99
|
+
if (isWithin(outputDir, absolutePath) || ignored.some((value) => isWithin(value, absolutePath))) continue;
|
|
100
|
+
const relativePath = path.posix.join(relativeDir, entry.name);
|
|
101
|
+
const directoryLike = entry.isDirectory();
|
|
102
|
+
const matches = (matcher) => matcher(relativePath) || directoryLike && matcher(`${relativePath}/`);
|
|
103
|
+
if (excluded.some(matches) || isIgnored(path.join(canonicalRoot, relativePath), directoryLike)) continue;
|
|
104
|
+
const includedHere = inheritedInclude || included.some(matches);
|
|
105
|
+
if (directoryLike) {
|
|
106
|
+
const hasSelectedChild = await visit(absolutePath, relativePath, includedHere);
|
|
107
|
+
if (included.length === 0 || includedHere || hasSelectedChild) {
|
|
108
|
+
selected.add(relativePath);
|
|
109
|
+
hasSelected = true;
|
|
110
|
+
}
|
|
111
|
+
} else if (included.length === 0 || includedHere) {
|
|
112
|
+
selected.add(relativePath);
|
|
113
|
+
hasSelected = true;
|
|
114
|
+
if (entry.isSymbolicLink()) symlinks.set(relativePath, await readlink(absolutePath));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return hasSelected;
|
|
118
|
+
}
|
|
119
|
+
if (!isIgnored(canonicalRoot, true)) await visit(sourceDir, "", false);
|
|
120
|
+
const mirroredSymlinks = /* @__PURE__ */ new Set();
|
|
121
|
+
for (const [relativePath, target] of symlinks) {
|
|
122
|
+
if (path.isAbsolute(target)) continue;
|
|
123
|
+
const targetPath = path.resolve(sourceDir, path.dirname(relativePath), target);
|
|
124
|
+
if (!isWithin(sourceDir, targetPath)) continue;
|
|
125
|
+
const targetRelativePath = path.relative(sourceDir, targetPath).split(path.sep).join("/");
|
|
126
|
+
if (selected.has(targetRelativePath)) mirroredSymlinks.add(relativePath);
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
selected,
|
|
130
|
+
symlinks: new Set(symlinks.keys()),
|
|
131
|
+
mirroredSymlinks
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/validation.ts
|
|
136
|
+
function optionObject(value, name) {
|
|
137
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError(`${name} must be an object`);
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
function distance(left, right) {
|
|
141
|
+
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
142
|
+
for (let i = 1; i <= left.length; i++) {
|
|
143
|
+
const current = [i];
|
|
144
|
+
for (let j = 1; j <= right.length; j++) current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1));
|
|
145
|
+
previous = current;
|
|
146
|
+
}
|
|
147
|
+
return previous[right.length];
|
|
148
|
+
}
|
|
149
|
+
function optionKeys(value, allowed, name) {
|
|
150
|
+
for (const key of Object.keys(value)) {
|
|
151
|
+
if (allowed.includes(key)) continue;
|
|
152
|
+
const suggestion = allowed.map((candidate) => ({
|
|
153
|
+
candidate,
|
|
154
|
+
distance: distance(key, candidate)
|
|
155
|
+
})).sort((left, right) => left.distance - right.distance).find((item) => item.distance <= 2)?.candidate;
|
|
156
|
+
throw new TypeError(`Unsupported option "${name}.${key}".${suggestion ? ` Did you mean "${name}.${suggestion}"?` : ` Supported keys: ${allowed.join(", ")}.`}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function scalar(value, type, name) {
|
|
160
|
+
if (value !== void 0 && typeof value !== type) throw new TypeError(`${name} must be a ${type}`);
|
|
161
|
+
}
|
|
162
|
+
function choice(value, allowed, name) {
|
|
163
|
+
if (value !== void 0 && !allowed.includes(value)) throw new TypeError(`${name} must be one of ${allowed.join(", ")}`);
|
|
164
|
+
}
|
|
165
|
+
function positiveInteger(value, name) {
|
|
166
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) throw new TypeError(`${name} must be a positive safe integer`);
|
|
167
|
+
}
|
|
168
|
+
function validateListingPlan(value) {
|
|
169
|
+
const plan = optionObject(value, "listing");
|
|
170
|
+
optionKeys(plan, ["html", "data"], "listing");
|
|
171
|
+
for (const [key, strategy, size] of [[
|
|
172
|
+
"html",
|
|
173
|
+
"paged",
|
|
174
|
+
"pageSize"
|
|
175
|
+
], [
|
|
176
|
+
"data",
|
|
177
|
+
"blocks",
|
|
178
|
+
"blockSize"
|
|
179
|
+
]]) {
|
|
180
|
+
const section = optionObject(plan[key], `listing.${key}`);
|
|
181
|
+
optionKeys(section, section.strategy === strategy ? ["strategy", size] : ["strategy"], `listing.${key}`);
|
|
182
|
+
if (section.strategy !== "all" && section.strategy !== strategy) throw new TypeError(`listing.${key}.strategy must be all or ${strategy}`);
|
|
183
|
+
if (section.strategy === strategy) positiveInteger(section[size], `listing.${key}.${size}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function selection(value, name) {
|
|
187
|
+
for (const key of ["include", "exclude"]) if (value[key] !== void 0 && typeof value[key] !== "string" && (!Array.isArray(value[key]) || value[key].some((item) => typeof item !== "string"))) throw new TypeError(`${name}.${key} must be a string or array of strings`);
|
|
188
|
+
resolveIgnoreFiles(value.ignoreFiles);
|
|
189
|
+
scalar(value.entryData, "function", `${name}.entryData`);
|
|
190
|
+
if (value.sort !== void 0) {
|
|
191
|
+
const sort = optionObject(value.sort, `${name}.sort`);
|
|
192
|
+
optionKeys(sort, [
|
|
193
|
+
"field",
|
|
194
|
+
"direction",
|
|
195
|
+
"directoriesFirst",
|
|
196
|
+
"nameMode"
|
|
197
|
+
], `${name}.sort`);
|
|
198
|
+
scalar(sort.directoriesFirst, "boolean", `${name}.sort.directoriesFirst`);
|
|
199
|
+
choice(sort.field, [
|
|
200
|
+
"name",
|
|
201
|
+
"size",
|
|
202
|
+
"modified"
|
|
203
|
+
], `${name}.sort.field`);
|
|
204
|
+
choice(sort.direction, ["asc", "desc"], `${name}.sort.direction`);
|
|
205
|
+
choice(sort.nameMode, [
|
|
206
|
+
"natural",
|
|
207
|
+
"locale",
|
|
208
|
+
"unicode"
|
|
209
|
+
], `${name}.sort.nameMode`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function requiredString(value, name) {
|
|
213
|
+
if (typeof value !== "string" || !value.length) throw new TypeError(`${name} must be a non-empty string`);
|
|
214
|
+
}
|
|
215
|
+
function validateTheme(value, name = "options.theme") {
|
|
216
|
+
if (value === void 0) throw new TypeError("Core requires an explicit theme");
|
|
217
|
+
const theme = optionObject(value, name);
|
|
218
|
+
requiredString(theme.name, `${name}.name`);
|
|
219
|
+
if (typeof theme.render !== "function") throw new TypeError(`${name}.render must be a function`);
|
|
220
|
+
scalar(theme.entryData, "function", `${name}.entryData`);
|
|
221
|
+
scalar(theme.searchIndex, "boolean", `${name}.searchIndex`);
|
|
222
|
+
if (theme.listing !== void 0) {
|
|
223
|
+
const listing = optionObject(theme.listing, `${name}.listing`);
|
|
224
|
+
optionKeys(listing, ["pagination"], `${name}.listing`);
|
|
225
|
+
if (typeof listing.pagination !== "boolean") throw new TypeError(`${name}.listing.pagination must be a boolean`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const generateKeys = {
|
|
229
|
+
sourceDir: true,
|
|
230
|
+
outputDir: true,
|
|
231
|
+
theme: true,
|
|
232
|
+
include: true,
|
|
233
|
+
exclude: true,
|
|
234
|
+
ignoreFiles: true,
|
|
235
|
+
sort: true,
|
|
236
|
+
entryData: true,
|
|
237
|
+
fileHref: true,
|
|
238
|
+
dryRun: true,
|
|
239
|
+
outputPolicy: true,
|
|
240
|
+
listing: true,
|
|
241
|
+
metadata: true,
|
|
242
|
+
base: true,
|
|
243
|
+
mode: true,
|
|
244
|
+
mirror: true,
|
|
245
|
+
outputName: true,
|
|
246
|
+
urlStrategy: true,
|
|
247
|
+
symlinks: true
|
|
248
|
+
};
|
|
249
|
+
function validateGenerateOptions(value) {
|
|
250
|
+
const options = optionObject(value, "options");
|
|
251
|
+
optionKeys(options, Object.keys(generateKeys), "options");
|
|
252
|
+
requiredString(options.sourceDir, "options.sourceDir");
|
|
253
|
+
requiredString(options.outputDir, "options.outputDir");
|
|
254
|
+
validateTheme(options.theme);
|
|
255
|
+
selection(options, "options");
|
|
256
|
+
for (const key of ["fileHref", "metadata"]) scalar(options[key], "function", `options.${key}`);
|
|
257
|
+
for (const key of ["dryRun", "mirror"]) scalar(options[key], "boolean", `options.${key}`);
|
|
258
|
+
scalar(options.base, "string", "options.base");
|
|
259
|
+
choice(options.mode, ["ssg", "mpa"], "options.mode");
|
|
260
|
+
choice(options.urlStrategy, Object.keys({
|
|
261
|
+
base: true,
|
|
262
|
+
"html-base": true,
|
|
263
|
+
relative: true
|
|
264
|
+
}), "options.urlStrategy");
|
|
265
|
+
choice(options.outputPolicy, ["owned", "replace"], "options.outputPolicy");
|
|
266
|
+
if (options.outputName !== void 0 && typeof options.outputName !== "string" && typeof options.outputName !== "function") throw new TypeError("options.outputName must be a string or function");
|
|
267
|
+
if (options.listing !== void 0 && typeof options.listing !== "function") validateListingPlan(options.listing);
|
|
268
|
+
if (options.symlinks !== void 0) {
|
|
269
|
+
const links = optionObject(options.symlinks, "options.symlinks");
|
|
270
|
+
optionKeys(links, [
|
|
271
|
+
"follow",
|
|
272
|
+
"boundary",
|
|
273
|
+
"onCycle"
|
|
274
|
+
], "options.symlinks");
|
|
275
|
+
scalar(links.follow, "boolean", "options.symlinks.follow");
|
|
276
|
+
choice(links.boundary, ["root", "anywhere"], "options.symlinks.boundary");
|
|
277
|
+
choice(links.onCycle, ["error", "skip"], "options.symlinks.onCycle");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const serviceKeys = {
|
|
281
|
+
root: true,
|
|
282
|
+
include: true,
|
|
283
|
+
exclude: true,
|
|
284
|
+
ignoreFiles: true,
|
|
285
|
+
sort: true,
|
|
286
|
+
entryData: true
|
|
287
|
+
};
|
|
288
|
+
function validateDirectoryOptions(value) {
|
|
289
|
+
const options = optionObject(value, "options");
|
|
290
|
+
optionKeys(options, Object.keys(serviceKeys), "options");
|
|
291
|
+
requiredString(options.root, "options.root");
|
|
292
|
+
selection(options, "options");
|
|
293
|
+
}
|
|
294
|
+
//#endregion
|
|
7
295
|
//#region src/filesystem.ts
|
|
8
296
|
function entryKind(stats) {
|
|
9
297
|
if (stats.isDirectory()) return "directory";
|
|
@@ -78,6 +366,7 @@ async function describeEntry({ absolutePath, relativePath, rootRealPath, ancesto
|
|
|
78
366
|
function projectListingEntry(entry, context) {
|
|
79
367
|
const link = entry.symlink;
|
|
80
368
|
return {
|
|
369
|
+
...entry.publicData === void 0 ? {} : { data: copyJsonObject(entry.publicData) },
|
|
81
370
|
id: entry.relativePath,
|
|
82
371
|
name: entry.name,
|
|
83
372
|
kind: entry.kind,
|
|
@@ -94,18 +383,13 @@ function projectListingEntry(entry, context) {
|
|
|
94
383
|
}
|
|
95
384
|
};
|
|
96
385
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (!record(value) || !record(value.html) || !record(value.data)) throw new TypeError("listing must contain html and data plans");
|
|
105
|
-
if (value.html.strategy === "paged") positiveInteger(value.html.pageSize, "listing.html.pageSize");
|
|
106
|
-
else if (value.html.strategy !== "all") throw new TypeError("listing.html.strategy must be \"all\" or \"paged\"");
|
|
107
|
-
if (value.data.strategy === "blocks") positiveInteger(value.data.blockSize, "listing.data.blockSize");
|
|
108
|
-
else if (value.data.strategy !== "all") throw new TypeError("listing.data.strategy must be \"all\" or \"blocks\"");
|
|
386
|
+
/** Annotation providers receive public facts, not transport URLs or raw stat objects. */
|
|
387
|
+
function entryDataFacts(entry) {
|
|
388
|
+
const { href: _href, exitsExplorer: _exits, data: _data, ...facts } = projectListingEntry(entry, {
|
|
389
|
+
hrefFor: () => null,
|
|
390
|
+
exitsExplorerFor: () => false
|
|
391
|
+
});
|
|
392
|
+
return facts;
|
|
109
393
|
}
|
|
110
394
|
function listingRevision(entries, sort, hrefBase, blockSize) {
|
|
111
395
|
return createHash("sha256").update(JSON.stringify({
|
|
@@ -117,69 +401,87 @@ function listingRevision(entries, sort, hrefBase, blockSize) {
|
|
|
117
401
|
})).digest("hex");
|
|
118
402
|
}
|
|
119
403
|
//#endregion
|
|
120
|
-
//#region src/
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const ignored = ignoredPaths.map((value) => path.resolve(value));
|
|
134
|
-
const included = normalizePathPatterns(include, "include").map((pattern) => picomatch(pattern, {
|
|
135
|
-
dot: true,
|
|
136
|
-
nonegate: true
|
|
137
|
-
}));
|
|
138
|
-
const excluded = normalizePathPatterns(exclude, "exclude").map((pattern) => picomatch(pattern, {
|
|
139
|
-
dot: true,
|
|
140
|
-
nonegate: true
|
|
141
|
-
}));
|
|
142
|
-
const selected = /* @__PURE__ */ new Set([""]);
|
|
143
|
-
const symlinks = /* @__PURE__ */ new Map();
|
|
144
|
-
async function visit(directory, relativeDir, inheritedInclude) {
|
|
145
|
-
let hasSelected = false;
|
|
146
|
-
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
147
|
-
const absolutePath = path.join(directory, entry.name);
|
|
148
|
-
if (isWithin(outputDir, absolutePath) || ignored.some((value) => isWithin(value, absolutePath))) continue;
|
|
149
|
-
const relativePath = path.posix.join(relativeDir, entry.name);
|
|
150
|
-
const directoryLike = entry.isDirectory();
|
|
151
|
-
const matches = (matcher) => matcher(relativePath) || directoryLike && matcher(`${relativePath}/`);
|
|
152
|
-
if (excluded.some(matches)) continue;
|
|
153
|
-
const includedHere = inheritedInclude || included.some(matches);
|
|
154
|
-
if (directoryLike) {
|
|
155
|
-
const hasSelectedChild = await visit(absolutePath, relativePath, includedHere);
|
|
156
|
-
if (included.length === 0 || includedHere || hasSelectedChild) {
|
|
157
|
-
selected.add(relativePath);
|
|
158
|
-
hasSelected = true;
|
|
159
|
-
}
|
|
160
|
-
} else if (included.length === 0 || includedHere) {
|
|
161
|
-
selected.add(relativePath);
|
|
162
|
-
hasSelected = true;
|
|
163
|
-
if (entry.isSymbolicLink()) symlinks.set(relativePath, await readlink(absolutePath));
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
return hasSelected;
|
|
404
|
+
//#region src/publication.ts
|
|
405
|
+
const outputMarker = "__dirwell/ownership.json";
|
|
406
|
+
const outputMarkerContents = "{\"type\":\"dirwell-output\",\"version\":1}\n";
|
|
407
|
+
async function lockOutput(output) {
|
|
408
|
+
const canonical = await canonicalPath(output);
|
|
409
|
+
const lockPath = path.join(path.dirname(canonical), `.${path.basename(canonical)}-dirwell.lock`);
|
|
410
|
+
await mkdir(path.dirname(lockPath), { recursive: true });
|
|
411
|
+
let handle;
|
|
412
|
+
try {
|
|
413
|
+
handle = await open(lockPath, "wx");
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST") throw new Error(`Output is locked by another build: ${lockPath}. Wait for it to finish. Remove a stale lock only after confirming its owner has stopped; --force does not bypass locks.`);
|
|
416
|
+
throw error;
|
|
167
417
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const targetRelativePath = path.relative(sourceDir, targetPath).split(path.sep).join("/");
|
|
175
|
-
if (selected.has(targetRelativePath)) mirroredSymlinks.add(relativePath);
|
|
418
|
+
try {
|
|
419
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid }));
|
|
420
|
+
} catch (error) {
|
|
421
|
+
await handle.close();
|
|
422
|
+
await rm(lockPath);
|
|
423
|
+
throw error;
|
|
176
424
|
}
|
|
177
425
|
return {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
426
|
+
lockPath,
|
|
427
|
+
async release() {
|
|
428
|
+
await handle.close();
|
|
429
|
+
await rm(lockPath);
|
|
430
|
+
}
|
|
181
431
|
};
|
|
182
432
|
}
|
|
433
|
+
async function canonicalPath(filename) {
|
|
434
|
+
try {
|
|
435
|
+
return await realpath(filename);
|
|
436
|
+
} catch (error) {
|
|
437
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
438
|
+
const parent = path.dirname(filename);
|
|
439
|
+
if (parent === filename) throw error;
|
|
440
|
+
return path.join(await canonicalPath(parent), path.basename(filename));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/** Force permits output ownership replacement, never source containment violations. */
|
|
444
|
+
async function checkOutput(source, output, policy = "owned") {
|
|
445
|
+
if (policy !== "owned" && policy !== "replace") throw new TypeError("outputPolicy must be owned or replace");
|
|
446
|
+
if (isInsideRoot(await canonicalPath(output), await realpath(source))) throw new Error("outputDir must not contain the sourceDir");
|
|
447
|
+
let stat;
|
|
448
|
+
try {
|
|
449
|
+
stat = await lstat(output);
|
|
450
|
+
} catch (error) {
|
|
451
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("outputDir must be a directory, not a file or symlink");
|
|
455
|
+
if ((await readdir(output)).length === 0 || policy === "replace") return true;
|
|
456
|
+
try {
|
|
457
|
+
const marker = path.join(output, outputMarker);
|
|
458
|
+
if (!(await lstat(path.dirname(marker))).isSymbolicLink() && !(await lstat(marker)).isSymbolicLink() && await readFile(marker, "utf8") === "{\"type\":\"dirwell-output\",\"version\":1}\n") return true;
|
|
459
|
+
} catch (error) {
|
|
460
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
461
|
+
}
|
|
462
|
+
throw new Error("Output is nonempty and not owned by Dirwell. Choose another directory or explicitly replace it with --force (-f) / outputPolicy: 'replace'.");
|
|
463
|
+
}
|
|
464
|
+
/** Keep a recoverable previous tree until the new tree is installed. */
|
|
465
|
+
async function publishOutput(staged, output, source, policy = "owned") {
|
|
466
|
+
const exists = await checkOutput(source, output, policy);
|
|
467
|
+
await mkdir(path.dirname(output), { recursive: true });
|
|
468
|
+
const backup = path.join(path.dirname(output), `.${path.basename(output)}-previous-${randomUUID()}`);
|
|
469
|
+
if (exists) await rename(output, backup);
|
|
470
|
+
try {
|
|
471
|
+
await rename(staged, output);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (exists) try {
|
|
474
|
+
await rename(backup, output);
|
|
475
|
+
} catch (restoreError) {
|
|
476
|
+
throw new AggregateError([error, restoreError], `Publication failed; previous output is retained at ${backup}`);
|
|
477
|
+
}
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
if (exists) await rm(backup, {
|
|
481
|
+
recursive: true,
|
|
482
|
+
force: true
|
|
483
|
+
});
|
|
484
|
+
}
|
|
183
485
|
//#endregion
|
|
184
486
|
//#region src/generator.ts
|
|
185
487
|
const indexPattern = /^index\.html?$/i;
|
|
@@ -250,10 +552,28 @@ async function generateExplorer(options) {
|
|
|
250
552
|
}
|
|
251
553
|
/** Internal entry point for hosts that stage previews outside their published output. */
|
|
252
554
|
async function generateExplorerSkippingPaths(options, ignoredPaths) {
|
|
253
|
-
|
|
254
|
-
const
|
|
555
|
+
validateGenerateOptions(options);
|
|
556
|
+
const source = path.resolve(options.sourceDir), output = path.resolve(options.outputDir);
|
|
557
|
+
await checkOutput(source, output, options.outputPolicy);
|
|
558
|
+
if (options.dryRun) return generateExplorerUnlocked(options, ignoredPaths);
|
|
559
|
+
const lock = await lockOutput(output);
|
|
560
|
+
const lexicalLock = path.join(path.dirname(output), path.basename(lock.lockPath));
|
|
561
|
+
try {
|
|
562
|
+
return await generateExplorerUnlocked(options, [
|
|
563
|
+
...ignoredPaths,
|
|
564
|
+
lock.lockPath,
|
|
565
|
+
lexicalLock
|
|
566
|
+
]);
|
|
567
|
+
} finally {
|
|
568
|
+
await lock.release();
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
async function generateExplorerUnlocked(options, ignoredPaths) {
|
|
572
|
+
const sourceDir = await realpath(path.resolve(options.sourceDir));
|
|
573
|
+
const outputDir = await canonicalPath(path.resolve(options.outputDir));
|
|
255
574
|
const ignoredSourcePaths = ignoredPaths.map((value) => path.resolve(value));
|
|
256
575
|
const rootRealPath = await realpath(sourceDir);
|
|
576
|
+
const replacesOutput = await checkOutput(sourceDir, outputDir, options.outputPolicy);
|
|
257
577
|
const outputName = options.outputName ?? defaultOutputName;
|
|
258
578
|
const resolveOutputName = typeof outputName === "string" ? () => outputName : outputName;
|
|
259
579
|
const theme = options.theme;
|
|
@@ -264,7 +584,7 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
|
|
|
264
584
|
const followSymlinks = options.symlinks?.follow ?? false;
|
|
265
585
|
const boundary = options.symlinks?.boundary ?? "root";
|
|
266
586
|
const onCycle = options.symlinks?.onCycle ?? "skip";
|
|
267
|
-
const filtersActive = options.include !== void 0 || options.exclude !== void 0 || ignoredSourcePaths.length > 0;
|
|
587
|
+
const filtersActive = options.include !== void 0 || options.exclude !== void 0 || (options.ignoreFiles?.length ?? 0) > 0 || ignoredSourcePaths.length > 0;
|
|
268
588
|
const plans = [];
|
|
269
589
|
if (isInsideRoot(outputDir, sourceDir)) throw new Error("outputDir must not contain the sourceDir");
|
|
270
590
|
try {
|
|
@@ -274,13 +594,13 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
|
|
|
274
594
|
if (error instanceof Error && "code" in error && error.code === "ENOENT") {} else throw error;
|
|
275
595
|
}
|
|
276
596
|
const outputIsWithinSource = isInsideRoot(sourceDir, outputDir);
|
|
277
|
-
const selection = await selectSourcePaths(sourceDir, outputDir, options.include, options.exclude, ignoredSourcePaths);
|
|
597
|
+
const selection = await selectSourcePaths(sourceDir, outputDir, options.include, options.exclude, ignoredSourcePaths, options.ignoreFiles);
|
|
278
598
|
const stageParent = outputIsWithinSource ? path.dirname(sourceDir) : path.dirname(outputDir);
|
|
279
|
-
await mkdir(stageParent, { recursive: true });
|
|
280
|
-
const stagedOutputDir = await mkdtemp(path.join(stageParent, `.${path.basename(outputDir)}-dirwell-`));
|
|
599
|
+
if (!options.dryRun) await mkdir(stageParent, { recursive: true });
|
|
600
|
+
const stagedOutputDir = options.dryRun ? null : await mkdtemp(path.join(stageParent, `.${path.basename(outputDir)}-dirwell-`));
|
|
281
601
|
const buildOutputDir = stagedOutputDir;
|
|
282
602
|
try {
|
|
283
|
-
if (options.mirror ?? true) {
|
|
603
|
+
if (!options.dryRun && (options.mirror ?? true)) {
|
|
284
604
|
await rm(buildOutputDir, {
|
|
285
605
|
recursive: true,
|
|
286
606
|
force: true
|
|
@@ -297,7 +617,7 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
|
|
|
297
617
|
});
|
|
298
618
|
}
|
|
299
619
|
} catch (error) {
|
|
300
|
-
await rm(stagedOutputDir, {
|
|
620
|
+
if (stagedOutputDir !== null) await rm(stagedOutputDir, {
|
|
301
621
|
recursive: true,
|
|
302
622
|
force: true
|
|
303
623
|
});
|
|
@@ -317,14 +637,23 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
|
|
|
317
637
|
const relativePath = path.relative(rootRealPath, path.join(physicalRealPath, name));
|
|
318
638
|
return selection.selected.has(relativePath.split(path.sep).join("/"));
|
|
319
639
|
});
|
|
320
|
-
const entries = await Promise.all(names.map((name) =>
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
640
|
+
const entries = await Promise.all(names.map(async (name) => {
|
|
641
|
+
const entry = await describeEntry({
|
|
642
|
+
absolutePath: path.join(physicalDir, name),
|
|
643
|
+
relativePath: path.posix.join(logicalDir, name),
|
|
644
|
+
rootRealPath,
|
|
645
|
+
ancestors: nextAncestors,
|
|
646
|
+
followSymlinks,
|
|
647
|
+
selectedPaths: filtersActive ? selection.selected : null
|
|
648
|
+
});
|
|
649
|
+
return options.entryData === void 0 ? entry : {
|
|
650
|
+
...entry,
|
|
651
|
+
publicData: copyJsonObject(await options.entryData(entryDataFacts(entry), {
|
|
652
|
+
operation: "build",
|
|
653
|
+
directoryPath: logicalDir
|
|
654
|
+
}))
|
|
655
|
+
};
|
|
656
|
+
}));
|
|
328
657
|
entries.sort((left, right) => compareEntries(left, right, options.sort) || (options.listing === void 0 ? 0 : compareUnicode(left.relativePath, right.relativePath)));
|
|
329
658
|
const describedCurrent = await describeEntry({
|
|
330
659
|
absolutePath: physicalDir,
|
|
@@ -388,6 +717,20 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
|
|
|
388
717
|
}
|
|
389
718
|
try {
|
|
390
719
|
await visit("", sourceDir, /* @__PURE__ */ new Set());
|
|
720
|
+
const report = {
|
|
721
|
+
sourceDir,
|
|
722
|
+
outputDir,
|
|
723
|
+
theme: theme.name,
|
|
724
|
+
dryRun: options.dryRun ?? false,
|
|
725
|
+
mirror: options.mirror ?? true,
|
|
726
|
+
replacesOutput,
|
|
727
|
+
selectedPaths: [...selection.selected].filter(Boolean).sort(),
|
|
728
|
+
pages: plans.map((plan) => ({
|
|
729
|
+
directory: plan.logicalDir,
|
|
730
|
+
outputName: plan.outputName
|
|
731
|
+
}))
|
|
732
|
+
};
|
|
733
|
+
if (options.dryRun) return report;
|
|
391
734
|
const generatedPages = new Map(plans.filter((plan) => plan.outputName !== null).map((plan) => [plan.logicalDir, plan.outputName]));
|
|
392
735
|
const generatedDirectories = new Set(generatedPages.keys());
|
|
393
736
|
const pagePathForDirectory = (logicalPath) => {
|
|
@@ -406,6 +749,15 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
|
|
|
406
749
|
const sharedAssets = /* @__PURE__ */ new Map();
|
|
407
750
|
const rawLinkFiles = /* @__PURE__ */ new Map();
|
|
408
751
|
const searchEntries = /* @__PURE__ */ new Map();
|
|
752
|
+
const fileHrefs = /* @__PURE__ */ new Map();
|
|
753
|
+
const mappedFileHref = (logicalPath) => {
|
|
754
|
+
if (!fileHrefs.has(logicalPath)) {
|
|
755
|
+
const href = options.fileHref(logicalPath);
|
|
756
|
+
if (href !== null && (typeof href !== "string" || href.length === 0 || href.includes("\\") || Array.from(href).some((character) => character.charCodeAt(0) < 32) || !["http:", "https:"].includes(new URL(href, "https://dirwell.invalid/").protocol))) throw new TypeError("fileHref must return a synchronous HTTP(S), root-relative or relative URL, or null");
|
|
757
|
+
fileHrefs.set(logicalPath, href);
|
|
758
|
+
}
|
|
759
|
+
return fileHrefs.get(logicalPath);
|
|
760
|
+
};
|
|
409
761
|
for (const { directory, logicalDir } of theme.searchIndex === false ? [] : plans) for (const entry of directory.entries) {
|
|
410
762
|
if (searchEntries.has(entry.relativePath)) continue;
|
|
411
763
|
const targetLogicalPath = isUnavailableSymlink(entry) ? null : entry.symlink?.targetRelativePath !== null && entry.symlink !== null ? entry.symlink.targetRelativePath : path.posix.join(logicalDir, entry.name);
|
|
@@ -416,7 +768,10 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
|
|
|
416
768
|
const filename = rawLinkFilename(entry);
|
|
417
769
|
rawLinkFiles.set(filename, entry.symlink.target);
|
|
418
770
|
href = `raw-links/${filename}`;
|
|
419
|
-
} else if (linkedPath !== null)
|
|
771
|
+
} else if (linkedPath !== null) {
|
|
772
|
+
href = linkedPath === "" ? "../" : `../${encodeLogicalPath(linkedPath)}${directoryLike && linkedPath === targetLogicalPath ? "/" : ""}`;
|
|
773
|
+
if (!directoryLike && options.fileHref !== void 0) href = mappedFileHref(linkedPath);
|
|
774
|
+
}
|
|
420
775
|
searchEntries.set(entry.relativePath, {
|
|
421
776
|
exitsExplorer: isUnavailableSymlink(entry) || targetLogicalPath !== null && (!directoryLike || !generatedDirectories.has(targetLogicalPath)),
|
|
422
777
|
href,
|
|
@@ -460,6 +815,7 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
|
|
|
460
815
|
const targetLogicalPath = targetLogicalPathFor(entry);
|
|
461
816
|
if (targetLogicalPath === null) return null;
|
|
462
817
|
const directoryLike = entry.kind === "directory" || entry.symlink?.targetKind === "directory";
|
|
818
|
+
if (!directoryLike && options.fileHref !== void 0) return mappedFileHref(targetLogicalPath);
|
|
463
819
|
return hrefForLogicalPath(targetLogicalPath, directoryLike);
|
|
464
820
|
};
|
|
465
821
|
const baseContext = {
|
|
@@ -652,14 +1008,12 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
|
|
|
652
1008
|
version: 3
|
|
653
1009
|
}));
|
|
654
1010
|
}
|
|
655
|
-
await
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
await mkdir(path.dirname(outputDir), { recursive: true });
|
|
660
|
-
await rename(stagedOutputDir, outputDir);
|
|
1011
|
+
await mkdir(path.join(buildOutputDir, "__dirwell"), { recursive: true });
|
|
1012
|
+
await writeFile(path.join(buildOutputDir, outputMarker), outputMarkerContents);
|
|
1013
|
+
await publishOutput(buildOutputDir, outputDir, sourceDir, options.outputPolicy);
|
|
1014
|
+
return report;
|
|
661
1015
|
} finally {
|
|
662
|
-
await rm(stagedOutputDir, {
|
|
1016
|
+
if (stagedOutputDir !== null) await rm(stagedOutputDir, {
|
|
663
1017
|
recursive: true,
|
|
664
1018
|
force: true
|
|
665
1019
|
});
|
|
@@ -692,8 +1046,17 @@ function assertLogicalPath(value) {
|
|
|
692
1046
|
}
|
|
693
1047
|
/** Live reads; no watcher, recursive stat snapshot, output tree or implicit cache. */
|
|
694
1048
|
async function createDirectoryService(options) {
|
|
1049
|
+
validateDirectoryOptions(options);
|
|
695
1050
|
const root = await realpath(options.root);
|
|
696
|
-
const
|
|
1051
|
+
const ignoreFiles = resolveIgnoreFiles(options.ignoreFiles);
|
|
1052
|
+
await createIgnoreMatcher(ignoreFiles);
|
|
1053
|
+
normalizePathPatterns(options.include, "include");
|
|
1054
|
+
normalizePathPatterns(options.exclude, "exclude");
|
|
1055
|
+
const selected = async () => {
|
|
1056
|
+
const matcher = await createIgnoreMatcher(ignoreFiles);
|
|
1057
|
+
if (normalizePathPatterns(options.include, "include").length === 0 && normalizePathPatterns(options.exclude, "exclude").length === 0 && !matcher.hasRules) return null;
|
|
1058
|
+
return (await selectSourcePaths(root, path.join(root, "__dirwell"), options.include, options.exclude, [], ignoreFiles, matcher)).selected;
|
|
1059
|
+
};
|
|
697
1060
|
async function describe(logicalPath, selection) {
|
|
698
1061
|
assertLogicalPath(logicalPath);
|
|
699
1062
|
if (selection !== null && !selection.has(logicalPath)) throw new DirectoryAccessError("Unavailable path");
|
|
@@ -762,12 +1125,20 @@ async function createDirectoryService(options) {
|
|
|
762
1125
|
if (!isInsideRoot(root, physical)) throw new DirectoryAccessError("Unavailable directory");
|
|
763
1126
|
const names = (await readdir(physical)).filter((name) => logicalPath !== "" || name !== "__dirwell");
|
|
764
1127
|
const entries = (await Promise.all(names.filter((name) => selection === null || selection.has(path.posix.join(logicalPath, name))).map(async (name) => {
|
|
1128
|
+
let entry;
|
|
765
1129
|
try {
|
|
766
|
-
|
|
1130
|
+
entry = await describe(path.posix.join(logicalPath, name), selection);
|
|
767
1131
|
} catch (error) {
|
|
768
1132
|
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
|
|
769
1133
|
throw error;
|
|
770
1134
|
}
|
|
1135
|
+
return options.entryData === void 0 ? entry : {
|
|
1136
|
+
...entry,
|
|
1137
|
+
publicData: copyJsonObject(await options.entryData(entryDataFacts(entry), {
|
|
1138
|
+
operation: "server",
|
|
1139
|
+
directoryPath: logicalPath
|
|
1140
|
+
}))
|
|
1141
|
+
};
|
|
771
1142
|
}))).filter((entry) => entry !== null);
|
|
772
1143
|
entries.sort((left, right) => compareEntries(left, right, options.sort) || (left.relativePath < right.relativePath ? -1 : left.relativePath > right.relativePath ? 1 : 0));
|
|
773
1144
|
return {
|
|
@@ -784,6 +1155,6 @@ async function createDirectoryService(options) {
|
|
|
784
1155
|
};
|
|
785
1156
|
}
|
|
786
1157
|
//#endregion
|
|
787
|
-
export { DirectoryAccessError, compareEntries, copyJsonObject, createDirectoryService, defaultOutputName, escapeHtml, generateExplorer, generateExplorerSkippingPaths, listingRevision, projectListingEntry, readGeneratedPage, validateListingPlan };
|
|
1158
|
+
export { DirectoryAccessError, compareEntries, copyJsonObject, createDirectoryService, defaultOutputName, escapeHtml, generateExplorer, generateExplorerSkippingPaths, listingRevision, projectListingEntry, readGeneratedPage, resolveIgnoreFiles, validateListingPlan };
|
|
788
1159
|
|
|
789
1160
|
//# sourceMappingURL=index.mjs.map
|