@dirwell/core 0.0.1-alpha.1 → 0.0.2

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/dist/index.mjs CHANGED
@@ -1,9 +1,313 @@
1
1
  import { t as copyJsonObject } from "./json-Bvj6xQLq.mjs";
2
- import { cp, lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { cp, lstat, mkdir, mkdtemp, open, opendir, 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
+ var SelectionBudgetError = class extends Error {
81
+ name = "SelectionBudgetError";
82
+ };
83
+ /** Select physical source paths; parent directories remain when a descendant matches. */
84
+ async function selectSourcePaths(sourceDir, outputDir, include, exclude, ignoredPaths = [], ignoreFiles = [], preparedIgnore, budget) {
85
+ const isIgnored = preparedIgnore ?? await createIgnoreMatcher(ignoreFiles);
86
+ const canonicalRoot = await realpath(sourceDir);
87
+ const ignored = ignoredPaths.map((value) => path.resolve(value));
88
+ const included = normalizePathPatterns(include, "include").map((pattern) => picomatch(pattern, {
89
+ dot: true,
90
+ nonegate: true
91
+ }));
92
+ const excluded = normalizePathPatterns(exclude, "exclude").map((pattern) => picomatch(pattern, {
93
+ dot: true,
94
+ nonegate: true
95
+ }));
96
+ const selected = /* @__PURE__ */ new Set([""]);
97
+ const symlinks = /* @__PURE__ */ new Map();
98
+ let entries = 0;
99
+ let directories = 0;
100
+ async function visit(directory, relativeDir, inheritedInclude) {
101
+ budget?.signal.throwIfAborted();
102
+ if (budget) {
103
+ const physical = await realpath(directory);
104
+ if (!isWithin(canonicalRoot, physical)) return false;
105
+ if (path.relative(canonicalRoot, physical).split(path.sep).join("/") !== relativeDir) return false;
106
+ }
107
+ if (budget && !await budget.allowPath(relativeDir)) return false;
108
+ if (budget && ++directories > budget.maxDirectories) throw new SelectionBudgetError("Directory scan budget exceeded");
109
+ let hasSelected = false;
110
+ for await (const entry of await opendir(directory)) {
111
+ budget?.signal.throwIfAborted();
112
+ if (budget && ++entries > budget.maxEntries) throw new SelectionBudgetError("Entry scan budget exceeded");
113
+ const absolutePath = path.join(directory, entry.name);
114
+ if (isWithin(outputDir, absolutePath) || ignored.some((value) => isWithin(value, absolutePath))) continue;
115
+ const relativePath = path.posix.join(relativeDir, entry.name);
116
+ if (budget && !await budget.allowPath(relativePath)) continue;
117
+ const directoryLike = entry.isDirectory();
118
+ const matches = (matcher) => matcher(relativePath) || directoryLike && matcher(`${relativePath}/`);
119
+ if (excluded.some(matches) || isIgnored(path.join(canonicalRoot, relativePath), directoryLike)) continue;
120
+ const includedHere = inheritedInclude || included.some(matches);
121
+ if (directoryLike) {
122
+ const hasSelectedChild = await visit(absolutePath, relativePath, includedHere);
123
+ if (included.length === 0 || includedHere || hasSelectedChild) {
124
+ selected.add(relativePath);
125
+ hasSelected = true;
126
+ }
127
+ } else if (included.length === 0 || includedHere) {
128
+ selected.add(relativePath);
129
+ hasSelected = true;
130
+ if (entry.isSymbolicLink()) symlinks.set(relativePath, await readlink(absolutePath));
131
+ }
132
+ }
133
+ return hasSelected;
134
+ }
135
+ if (!isIgnored(canonicalRoot, true)) await visit(sourceDir, "", false);
136
+ const mirroredSymlinks = /* @__PURE__ */ new Set();
137
+ for (const [relativePath, target] of symlinks) {
138
+ if (path.isAbsolute(target)) continue;
139
+ const targetPath = path.resolve(sourceDir, path.dirname(relativePath), target);
140
+ if (!isWithin(sourceDir, targetPath)) continue;
141
+ const targetRelativePath = path.relative(sourceDir, targetPath).split(path.sep).join("/");
142
+ if (selected.has(targetRelativePath)) mirroredSymlinks.add(relativePath);
143
+ }
144
+ return {
145
+ selected,
146
+ symlinks: new Set(symlinks.keys()),
147
+ mirroredSymlinks
148
+ };
149
+ }
150
+ //#endregion
151
+ //#region src/validation.ts
152
+ function optionObject(value, name) {
153
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError(`${name} must be an object`);
154
+ return value;
155
+ }
156
+ function distance(left, right) {
157
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
158
+ for (let i = 1; i <= left.length; i++) {
159
+ const current = [i];
160
+ 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));
161
+ previous = current;
162
+ }
163
+ return previous[right.length];
164
+ }
165
+ function optionKeys(value, allowed, name) {
166
+ for (const key of Object.keys(value)) {
167
+ if (allowed.includes(key)) continue;
168
+ const suggestion = allowed.map((candidate) => ({
169
+ candidate,
170
+ distance: distance(key, candidate)
171
+ })).sort((left, right) => left.distance - right.distance).find((item) => item.distance <= 2)?.candidate;
172
+ throw new TypeError(`Unsupported option "${name}.${key}".${suggestion ? ` Did you mean "${name}.${suggestion}"?` : ` Supported keys: ${allowed.join(", ")}.`}`);
173
+ }
174
+ }
175
+ function scalar(value, type, name) {
176
+ if (value !== void 0 && typeof value !== type) throw new TypeError(`${name} must be a ${type}`);
177
+ }
178
+ function choice(value, allowed, name) {
179
+ if (value !== void 0 && !allowed.includes(value)) throw new TypeError(`${name} must be one of ${allowed.join(", ")}`);
180
+ }
181
+ function positiveInteger(value, name) {
182
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) throw new TypeError(`${name} must be a positive safe integer`);
183
+ }
184
+ function validateListingPlan(value) {
185
+ const plan = optionObject(value, "listing");
186
+ optionKeys(plan, ["html", "data"], "listing");
187
+ for (const [key, strategy, size] of [[
188
+ "html",
189
+ "paged",
190
+ "pageSize"
191
+ ], [
192
+ "data",
193
+ "blocks",
194
+ "blockSize"
195
+ ]]) {
196
+ const section = optionObject(plan[key], `listing.${key}`);
197
+ optionKeys(section, section.strategy === strategy ? ["strategy", size] : ["strategy"], `listing.${key}`);
198
+ if (section.strategy !== "all" && section.strategy !== strategy) throw new TypeError(`listing.${key}.strategy must be all or ${strategy}`);
199
+ if (section.strategy === strategy) positiveInteger(section[size], `listing.${key}.${size}`);
200
+ }
201
+ }
202
+ function selection(value, name) {
203
+ 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`);
204
+ resolveIgnoreFiles(value.ignoreFiles);
205
+ scalar(value.entryData, "function", `${name}.entryData`);
206
+ if (value.sort !== void 0) {
207
+ const sort = optionObject(value.sort, `${name}.sort`);
208
+ optionKeys(sort, [
209
+ "field",
210
+ "direction",
211
+ "directoriesFirst",
212
+ "nameMode"
213
+ ], `${name}.sort`);
214
+ scalar(sort.directoriesFirst, "boolean", `${name}.sort.directoriesFirst`);
215
+ choice(sort.field, [
216
+ "name",
217
+ "size",
218
+ "modified"
219
+ ], `${name}.sort.field`);
220
+ choice(sort.direction, ["asc", "desc"], `${name}.sort.direction`);
221
+ choice(sort.nameMode, [
222
+ "natural",
223
+ "locale",
224
+ "unicode"
225
+ ], `${name}.sort.nameMode`);
226
+ }
227
+ }
228
+ function requiredString(value, name) {
229
+ if (typeof value !== "string" || !value.length) throw new TypeError(`${name} must be a non-empty string`);
230
+ }
231
+ function validateTheme(value, name = "options.theme") {
232
+ if (value === void 0) throw new TypeError("Core requires an explicit theme");
233
+ const theme = optionObject(value, name);
234
+ requiredString(theme.name, `${name}.name`);
235
+ if (typeof theme.render !== "function") throw new TypeError(`${name}.render must be a function`);
236
+ scalar(theme.entryData, "function", `${name}.entryData`);
237
+ scalar(theme.searchIndex, "boolean", `${name}.searchIndex`);
238
+ if (theme.listing !== void 0) {
239
+ const listing = optionObject(theme.listing, `${name}.listing`);
240
+ optionKeys(listing, ["pagination"], `${name}.listing`);
241
+ if (typeof listing.pagination !== "boolean") throw new TypeError(`${name}.listing.pagination must be a boolean`);
242
+ }
243
+ }
244
+ const generateKeys = {
245
+ sourceDir: true,
246
+ outputDir: true,
247
+ theme: true,
248
+ include: true,
249
+ exclude: true,
250
+ ignoreFiles: true,
251
+ sort: true,
252
+ entryData: true,
253
+ fileHref: true,
254
+ dryRun: true,
255
+ outputPolicy: true,
256
+ listing: true,
257
+ metadata: true,
258
+ base: true,
259
+ mode: true,
260
+ mirror: true,
261
+ outputName: true,
262
+ urlStrategy: true,
263
+ symlinks: true
264
+ };
265
+ function validateGenerateOptions(value) {
266
+ const options = optionObject(value, "options");
267
+ optionKeys(options, Object.keys(generateKeys), "options");
268
+ requiredString(options.sourceDir, "options.sourceDir");
269
+ requiredString(options.outputDir, "options.outputDir");
270
+ validateTheme(options.theme);
271
+ selection(options, "options");
272
+ for (const key of ["fileHref", "metadata"]) scalar(options[key], "function", `options.${key}`);
273
+ for (const key of ["dryRun", "mirror"]) scalar(options[key], "boolean", `options.${key}`);
274
+ scalar(options.base, "string", "options.base");
275
+ choice(options.mode, ["ssg", "mpa"], "options.mode");
276
+ choice(options.urlStrategy, Object.keys({
277
+ base: true,
278
+ "html-base": true,
279
+ relative: true
280
+ }), "options.urlStrategy");
281
+ choice(options.outputPolicy, ["owned", "replace"], "options.outputPolicy");
282
+ if (options.outputName !== void 0 && typeof options.outputName !== "string" && typeof options.outputName !== "function") throw new TypeError("options.outputName must be a string or function");
283
+ if (options.listing !== void 0 && typeof options.listing !== "function") validateListingPlan(options.listing);
284
+ if (options.symlinks !== void 0) {
285
+ const links = optionObject(options.symlinks, "options.symlinks");
286
+ optionKeys(links, [
287
+ "follow",
288
+ "boundary",
289
+ "onCycle"
290
+ ], "options.symlinks");
291
+ scalar(links.follow, "boolean", "options.symlinks.follow");
292
+ choice(links.boundary, ["root", "anywhere"], "options.symlinks.boundary");
293
+ choice(links.onCycle, ["error", "skip"], "options.symlinks.onCycle");
294
+ }
295
+ }
296
+ const serviceKeys = {
297
+ root: true,
298
+ include: true,
299
+ exclude: true,
300
+ ignoreFiles: true,
301
+ sort: true,
302
+ entryData: true
303
+ };
304
+ function validateDirectoryOptions(value) {
305
+ const options = optionObject(value, "options");
306
+ optionKeys(options, Object.keys(serviceKeys), "options");
307
+ requiredString(options.root, "options.root");
308
+ selection(options, "options");
309
+ }
310
+ //#endregion
7
311
  //#region src/filesystem.ts
8
312
  function entryKind(stats) {
9
313
  if (stats.isDirectory()) return "directory";
@@ -78,6 +382,7 @@ async function describeEntry({ absolutePath, relativePath, rootRealPath, ancesto
78
382
  function projectListingEntry(entry, context) {
79
383
  const link = entry.symlink;
80
384
  return {
385
+ ...entry.publicData === void 0 ? {} : { data: copyJsonObject(entry.publicData) },
81
386
  id: entry.relativePath,
82
387
  name: entry.name,
83
388
  kind: entry.kind,
@@ -94,18 +399,13 @@ function projectListingEntry(entry, context) {
94
399
  }
95
400
  };
96
401
  }
97
- function record(value) {
98
- return typeof value === "object" && value !== null && !Array.isArray(value);
99
- }
100
- function positiveInteger(value, key) {
101
- if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) throw new TypeError(`${key} must be a positive safe integer`);
102
- }
103
- function validateListingPlan(value) {
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\"");
402
+ /** Annotation providers receive public facts, not transport URLs or raw stat objects. */
403
+ function entryDataFacts(entry) {
404
+ const { href: _href, exitsExplorer: _exits, data: _data, ...facts } = projectListingEntry(entry, {
405
+ hrefFor: () => null,
406
+ exitsExplorerFor: () => false
407
+ });
408
+ return facts;
109
409
  }
110
410
  function listingRevision(entries, sort, hrefBase, blockSize) {
111
411
  return createHash("sha256").update(JSON.stringify({
@@ -117,69 +417,87 @@ function listingRevision(entries, sort, hrefBase, blockSize) {
117
417
  })).digest("hex");
118
418
  }
119
419
  //#endregion
120
- //#region src/filters.ts
121
- function normalizePathPatterns(value, name) {
122
- if (value === void 0) return [];
123
- const patterns = typeof value === "string" ? [value] : value;
124
- 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`);
125
- return patterns;
126
- }
127
- function isWithin(parent, candidate) {
128
- const relative = path.relative(parent, candidate);
129
- return relative === "" || relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
130
- }
131
- /** Select physical source paths; parent directories remain when a descendant matches. */
132
- async function selectSourcePaths(sourceDir, outputDir, include, exclude, ignoredPaths = []) {
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;
420
+ //#region src/publication.ts
421
+ const outputMarker = "__dirwell/ownership.json";
422
+ const outputMarkerContents = "{\"type\":\"dirwell-output\",\"version\":1}\n";
423
+ async function lockOutput(output) {
424
+ const canonical = await canonicalPath(output);
425
+ const lockPath = path.join(path.dirname(canonical), `.${path.basename(canonical)}-dirwell.lock`);
426
+ await mkdir(path.dirname(lockPath), { recursive: true });
427
+ let handle;
428
+ try {
429
+ handle = await open(lockPath, "wx");
430
+ } catch (error) {
431
+ 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.`);
432
+ throw error;
167
433
  }
168
- await visit(sourceDir, "", false);
169
- const mirroredSymlinks = /* @__PURE__ */ new Set();
170
- for (const [relativePath, target] of symlinks) {
171
- if (path.isAbsolute(target)) continue;
172
- const targetPath = path.resolve(sourceDir, path.dirname(relativePath), target);
173
- if (!isWithin(sourceDir, targetPath)) continue;
174
- const targetRelativePath = path.relative(sourceDir, targetPath).split(path.sep).join("/");
175
- if (selected.has(targetRelativePath)) mirroredSymlinks.add(relativePath);
434
+ try {
435
+ await handle.writeFile(JSON.stringify({ pid: process.pid }));
436
+ } catch (error) {
437
+ await handle.close();
438
+ await rm(lockPath);
439
+ throw error;
176
440
  }
177
441
  return {
178
- selected,
179
- symlinks: new Set(symlinks.keys()),
180
- mirroredSymlinks
442
+ lockPath,
443
+ async release() {
444
+ await handle.close();
445
+ await rm(lockPath);
446
+ }
181
447
  };
182
448
  }
449
+ async function canonicalPath(filename) {
450
+ try {
451
+ return await realpath(filename);
452
+ } catch (error) {
453
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
454
+ const parent = path.dirname(filename);
455
+ if (parent === filename) throw error;
456
+ return path.join(await canonicalPath(parent), path.basename(filename));
457
+ }
458
+ }
459
+ /** Force permits output ownership replacement, never source containment violations. */
460
+ async function checkOutput(source, output, policy = "owned") {
461
+ if (policy !== "owned" && policy !== "replace") throw new TypeError("outputPolicy must be owned or replace");
462
+ if (isInsideRoot(await canonicalPath(output), await realpath(source))) throw new Error("outputDir must not contain the sourceDir");
463
+ let stat;
464
+ try {
465
+ stat = await lstat(output);
466
+ } catch (error) {
467
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
468
+ throw error;
469
+ }
470
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("outputDir must be a directory, not a file or symlink");
471
+ if ((await readdir(output)).length === 0 || policy === "replace") return true;
472
+ try {
473
+ const marker = path.join(output, outputMarker);
474
+ if (!(await lstat(path.dirname(marker))).isSymbolicLink() && !(await lstat(marker)).isSymbolicLink() && await readFile(marker, "utf8") === "{\"type\":\"dirwell-output\",\"version\":1}\n") return true;
475
+ } catch (error) {
476
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
477
+ }
478
+ throw new Error("Output is nonempty and not owned by Dirwell. Choose another directory or explicitly replace it with --force (-f) / outputPolicy: 'replace'.");
479
+ }
480
+ /** Keep a recoverable previous tree until the new tree is installed. */
481
+ async function publishOutput(staged, output, source, policy = "owned") {
482
+ const exists = await checkOutput(source, output, policy);
483
+ await mkdir(path.dirname(output), { recursive: true });
484
+ const backup = path.join(path.dirname(output), `.${path.basename(output)}-previous-${randomUUID()}`);
485
+ if (exists) await rename(output, backup);
486
+ try {
487
+ await rename(staged, output);
488
+ } catch (error) {
489
+ if (exists) try {
490
+ await rename(backup, output);
491
+ } catch (restoreError) {
492
+ throw new AggregateError([error, restoreError], `Publication failed; previous output is retained at ${backup}`);
493
+ }
494
+ throw error;
495
+ }
496
+ if (exists) await rm(backup, {
497
+ recursive: true,
498
+ force: true
499
+ });
500
+ }
183
501
  //#endregion
184
502
  //#region src/generator.ts
185
503
  const indexPattern = /^index\.html?$/i;
@@ -250,10 +568,28 @@ async function generateExplorer(options) {
250
568
  }
251
569
  /** Internal entry point for hosts that stage previews outside their published output. */
252
570
  async function generateExplorerSkippingPaths(options, ignoredPaths) {
253
- const sourceDir = path.resolve(options.sourceDir);
254
- const outputDir = path.resolve(options.outputDir);
571
+ validateGenerateOptions(options);
572
+ const source = path.resolve(options.sourceDir), output = path.resolve(options.outputDir);
573
+ await checkOutput(source, output, options.outputPolicy);
574
+ if (options.dryRun) return generateExplorerUnlocked(options, ignoredPaths);
575
+ const lock = await lockOutput(output);
576
+ const lexicalLock = path.join(path.dirname(output), path.basename(lock.lockPath));
577
+ try {
578
+ return await generateExplorerUnlocked(options, [
579
+ ...ignoredPaths,
580
+ lock.lockPath,
581
+ lexicalLock
582
+ ]);
583
+ } finally {
584
+ await lock.release();
585
+ }
586
+ }
587
+ async function generateExplorerUnlocked(options, ignoredPaths) {
588
+ const sourceDir = await realpath(path.resolve(options.sourceDir));
589
+ const outputDir = await canonicalPath(path.resolve(options.outputDir));
255
590
  const ignoredSourcePaths = ignoredPaths.map((value) => path.resolve(value));
256
591
  const rootRealPath = await realpath(sourceDir);
592
+ const replacesOutput = await checkOutput(sourceDir, outputDir, options.outputPolicy);
257
593
  const outputName = options.outputName ?? defaultOutputName;
258
594
  const resolveOutputName = typeof outputName === "string" ? () => outputName : outputName;
259
595
  const theme = options.theme;
@@ -264,7 +600,7 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
264
600
  const followSymlinks = options.symlinks?.follow ?? false;
265
601
  const boundary = options.symlinks?.boundary ?? "root";
266
602
  const onCycle = options.symlinks?.onCycle ?? "skip";
267
- const filtersActive = options.include !== void 0 || options.exclude !== void 0 || ignoredSourcePaths.length > 0;
603
+ const filtersActive = options.include !== void 0 || options.exclude !== void 0 || (options.ignoreFiles?.length ?? 0) > 0 || ignoredSourcePaths.length > 0;
268
604
  const plans = [];
269
605
  if (isInsideRoot(outputDir, sourceDir)) throw new Error("outputDir must not contain the sourceDir");
270
606
  try {
@@ -274,13 +610,13 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
274
610
  if (error instanceof Error && "code" in error && error.code === "ENOENT") {} else throw error;
275
611
  }
276
612
  const outputIsWithinSource = isInsideRoot(sourceDir, outputDir);
277
- const selection = await selectSourcePaths(sourceDir, outputDir, options.include, options.exclude, ignoredSourcePaths);
613
+ const selection = await selectSourcePaths(sourceDir, outputDir, options.include, options.exclude, ignoredSourcePaths, options.ignoreFiles);
278
614
  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-`));
615
+ if (!options.dryRun) await mkdir(stageParent, { recursive: true });
616
+ const stagedOutputDir = options.dryRun ? null : await mkdtemp(path.join(stageParent, `.${path.basename(outputDir)}-dirwell-`));
281
617
  const buildOutputDir = stagedOutputDir;
282
618
  try {
283
- if (options.mirror ?? true) {
619
+ if (!options.dryRun && (options.mirror ?? true)) {
284
620
  await rm(buildOutputDir, {
285
621
  recursive: true,
286
622
  force: true
@@ -297,7 +633,7 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
297
633
  });
298
634
  }
299
635
  } catch (error) {
300
- await rm(stagedOutputDir, {
636
+ if (stagedOutputDir !== null) await rm(stagedOutputDir, {
301
637
  recursive: true,
302
638
  force: true
303
639
  });
@@ -317,14 +653,23 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
317
653
  const relativePath = path.relative(rootRealPath, path.join(physicalRealPath, name));
318
654
  return selection.selected.has(relativePath.split(path.sep).join("/"));
319
655
  });
320
- const entries = await Promise.all(names.map((name) => describeEntry({
321
- absolutePath: path.join(physicalDir, name),
322
- relativePath: path.posix.join(logicalDir, name),
323
- rootRealPath,
324
- ancestors: nextAncestors,
325
- followSymlinks,
326
- selectedPaths: filtersActive ? selection.selected : null
327
- })));
656
+ const entries = await Promise.all(names.map(async (name) => {
657
+ const entry = await describeEntry({
658
+ absolutePath: path.join(physicalDir, name),
659
+ relativePath: path.posix.join(logicalDir, name),
660
+ rootRealPath,
661
+ ancestors: nextAncestors,
662
+ followSymlinks,
663
+ selectedPaths: filtersActive ? selection.selected : null
664
+ });
665
+ return options.entryData === void 0 ? entry : {
666
+ ...entry,
667
+ publicData: copyJsonObject(await options.entryData(entryDataFacts(entry), {
668
+ operation: "build",
669
+ directoryPath: logicalDir
670
+ }))
671
+ };
672
+ }));
328
673
  entries.sort((left, right) => compareEntries(left, right, options.sort) || (options.listing === void 0 ? 0 : compareUnicode(left.relativePath, right.relativePath)));
329
674
  const describedCurrent = await describeEntry({
330
675
  absolutePath: physicalDir,
@@ -388,6 +733,20 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
388
733
  }
389
734
  try {
390
735
  await visit("", sourceDir, /* @__PURE__ */ new Set());
736
+ const report = {
737
+ sourceDir,
738
+ outputDir,
739
+ theme: theme.name,
740
+ dryRun: options.dryRun ?? false,
741
+ mirror: options.mirror ?? true,
742
+ replacesOutput,
743
+ selectedPaths: [...selection.selected].filter(Boolean).sort(),
744
+ pages: plans.map((plan) => ({
745
+ directory: plan.logicalDir,
746
+ outputName: plan.outputName
747
+ }))
748
+ };
749
+ if (options.dryRun) return report;
391
750
  const generatedPages = new Map(plans.filter((plan) => plan.outputName !== null).map((plan) => [plan.logicalDir, plan.outputName]));
392
751
  const generatedDirectories = new Set(generatedPages.keys());
393
752
  const pagePathForDirectory = (logicalPath) => {
@@ -406,6 +765,15 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
406
765
  const sharedAssets = /* @__PURE__ */ new Map();
407
766
  const rawLinkFiles = /* @__PURE__ */ new Map();
408
767
  const searchEntries = /* @__PURE__ */ new Map();
768
+ const fileHrefs = /* @__PURE__ */ new Map();
769
+ const mappedFileHref = (logicalPath) => {
770
+ if (!fileHrefs.has(logicalPath)) {
771
+ const href = options.fileHref(logicalPath);
772
+ 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");
773
+ fileHrefs.set(logicalPath, href);
774
+ }
775
+ return fileHrefs.get(logicalPath);
776
+ };
409
777
  for (const { directory, logicalDir } of theme.searchIndex === false ? [] : plans) for (const entry of directory.entries) {
410
778
  if (searchEntries.has(entry.relativePath)) continue;
411
779
  const targetLogicalPath = isUnavailableSymlink(entry) ? null : entry.symlink?.targetRelativePath !== null && entry.symlink !== null ? entry.symlink.targetRelativePath : path.posix.join(logicalDir, entry.name);
@@ -416,7 +784,10 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
416
784
  const filename = rawLinkFilename(entry);
417
785
  rawLinkFiles.set(filename, entry.symlink.target);
418
786
  href = `raw-links/${filename}`;
419
- } else if (linkedPath !== null) href = linkedPath === "" ? "../" : `../${encodeLogicalPath(linkedPath)}${directoryLike && linkedPath === targetLogicalPath ? "/" : ""}`;
787
+ } else if (linkedPath !== null) {
788
+ href = linkedPath === "" ? "../" : `../${encodeLogicalPath(linkedPath)}${directoryLike && linkedPath === targetLogicalPath ? "/" : ""}`;
789
+ if (!directoryLike && options.fileHref !== void 0) href = mappedFileHref(linkedPath);
790
+ }
420
791
  searchEntries.set(entry.relativePath, {
421
792
  exitsExplorer: isUnavailableSymlink(entry) || targetLogicalPath !== null && (!directoryLike || !generatedDirectories.has(targetLogicalPath)),
422
793
  href,
@@ -460,6 +831,7 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
460
831
  const targetLogicalPath = targetLogicalPathFor(entry);
461
832
  if (targetLogicalPath === null) return null;
462
833
  const directoryLike = entry.kind === "directory" || entry.symlink?.targetKind === "directory";
834
+ if (!directoryLike && options.fileHref !== void 0) return mappedFileHref(targetLogicalPath);
463
835
  return hrefForLogicalPath(targetLogicalPath, directoryLike);
464
836
  };
465
837
  const baseContext = {
@@ -652,14 +1024,12 @@ async function generateExplorerSkippingPaths(options, ignoredPaths) {
652
1024
  version: 3
653
1025
  }));
654
1026
  }
655
- await rm(outputDir, {
656
- recursive: true,
657
- force: true
658
- });
659
- await mkdir(path.dirname(outputDir), { recursive: true });
660
- await rename(stagedOutputDir, outputDir);
1027
+ await mkdir(path.join(buildOutputDir, "__dirwell"), { recursive: true });
1028
+ await writeFile(path.join(buildOutputDir, outputMarker), outputMarkerContents);
1029
+ await publishOutput(buildOutputDir, outputDir, sourceDir, options.outputPolicy);
1030
+ return report;
661
1031
  } finally {
662
- await rm(stagedOutputDir, {
1032
+ if (stagedOutputDir !== null) await rm(stagedOutputDir, {
663
1033
  recursive: true,
664
1034
  force: true
665
1035
  });
@@ -692,8 +1062,17 @@ function assertLogicalPath(value) {
692
1062
  }
693
1063
  /** Live reads; no watcher, recursive stat snapshot, output tree or implicit cache. */
694
1064
  async function createDirectoryService(options) {
1065
+ validateDirectoryOptions(options);
695
1066
  const root = await realpath(options.root);
696
- const selected = async () => options.include === void 0 && options.exclude === void 0 ? null : (await selectSourcePaths(root, path.join(root, "__dirwell"), options.include, options.exclude)).selected;
1067
+ const ignoreFiles = resolveIgnoreFiles(options.ignoreFiles);
1068
+ await createIgnoreMatcher(ignoreFiles);
1069
+ normalizePathPatterns(options.include, "include");
1070
+ normalizePathPatterns(options.exclude, "exclude");
1071
+ const selected = async () => {
1072
+ const matcher = await createIgnoreMatcher(ignoreFiles);
1073
+ if (normalizePathPatterns(options.include, "include").length === 0 && normalizePathPatterns(options.exclude, "exclude").length === 0 && !matcher.hasRules) return null;
1074
+ return (await selectSourcePaths(root, path.join(root, "__dirwell"), options.include, options.exclude, [], ignoreFiles, matcher)).selected;
1075
+ };
697
1076
  async function describe(logicalPath, selection) {
698
1077
  assertLogicalPath(logicalPath);
699
1078
  if (selection !== null && !selection.has(logicalPath)) throw new DirectoryAccessError("Unavailable path");
@@ -715,6 +1094,23 @@ async function createDirectoryService(options) {
715
1094
  });
716
1095
  }
717
1096
  return {
1097
+ async scan(budget) {
1098
+ if (!budget || !Number.isSafeInteger(budget.maxEntries) || budget.maxEntries < 1 || !Number.isSafeInteger(budget.maxDirectories) || budget.maxDirectories < 1 || !(budget.signal instanceof AbortSignal) || typeof budget.allowPath !== "function") throw new TypeError("scan requires positive entry/directory limits, an AbortSignal and allowPath");
1099
+ const selection = (await selectSourcePaths(root, path.join(root, "__dirwell"), options.include, options.exclude, [], ignoreFiles, void 0, budget)).selected;
1100
+ const entries = [];
1101
+ for (const logical of [...selection].sort()) {
1102
+ budget.signal.throwIfAborted();
1103
+ if (!logical) continue;
1104
+ try {
1105
+ const entry = await describe(logical, selection);
1106
+ if (entry.kind === "file" || entry.kind === "directory") entries.push(entry);
1107
+ } catch (error) {
1108
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") continue;
1109
+ throw error;
1110
+ }
1111
+ }
1112
+ return entries;
1113
+ },
718
1114
  async resolvePath(logicalPath) {
719
1115
  const selection = await selected();
720
1116
  const entry = await describe(logicalPath, selection);
@@ -762,12 +1158,20 @@ async function createDirectoryService(options) {
762
1158
  if (!isInsideRoot(root, physical)) throw new DirectoryAccessError("Unavailable directory");
763
1159
  const names = (await readdir(physical)).filter((name) => logicalPath !== "" || name !== "__dirwell");
764
1160
  const entries = (await Promise.all(names.filter((name) => selection === null || selection.has(path.posix.join(logicalPath, name))).map(async (name) => {
1161
+ let entry;
765
1162
  try {
766
- return await describe(path.posix.join(logicalPath, name), selection);
1163
+ entry = await describe(path.posix.join(logicalPath, name), selection);
767
1164
  } catch (error) {
768
1165
  if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
769
1166
  throw error;
770
1167
  }
1168
+ return options.entryData === void 0 ? entry : {
1169
+ ...entry,
1170
+ publicData: copyJsonObject(await options.entryData(entryDataFacts(entry), {
1171
+ operation: "server",
1172
+ directoryPath: logicalPath
1173
+ }))
1174
+ };
771
1175
  }))).filter((entry) => entry !== null);
772
1176
  entries.sort((left, right) => compareEntries(left, right, options.sort) || (left.relativePath < right.relativePath ? -1 : left.relativePath > right.relativePath ? 1 : 0));
773
1177
  return {
@@ -784,6 +1188,6 @@ async function createDirectoryService(options) {
784
1188
  };
785
1189
  }
786
1190
  //#endregion
787
- export { DirectoryAccessError, compareEntries, copyJsonObject, createDirectoryService, defaultOutputName, escapeHtml, generateExplorer, generateExplorerSkippingPaths, listingRevision, projectListingEntry, readGeneratedPage, validateListingPlan };
1191
+ export { DirectoryAccessError, SelectionBudgetError, compareEntries, copyJsonObject, createDirectoryService, defaultOutputName, escapeHtml, generateExplorer, generateExplorerSkippingPaths, listingRevision, projectListingEntry, readGeneratedPage, resolveIgnoreFiles, validateListingPlan };
788
1192
 
789
1193
  //# sourceMappingURL=index.mjs.map