@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/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
- 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\"");
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/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;
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
- 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);
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
- selected,
179
- symlinks: new Set(symlinks.keys()),
180
- mirroredSymlinks
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
- const sourceDir = path.resolve(options.sourceDir);
254
- const outputDir = path.resolve(options.outputDir);
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) => 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
- })));
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) href = linkedPath === "" ? "../" : `../${encodeLogicalPath(linkedPath)}${directoryLike && linkedPath === targetLogicalPath ? "/" : ""}`;
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 rm(outputDir, {
656
- recursive: true,
657
- force: true
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 selected = async () => options.include === void 0 && options.exclude === void 0 ? null : (await selectSourcePaths(root, path.join(root, "__dirwell"), options.include, options.exclude)).selected;
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
- return await describe(path.posix.join(logicalPath, name), selection);
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