@design-edito/tools 0.5.0 → 0.5.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.
Files changed (52) hide show
  1. package/agnostic/html/hyper-json/smart-tags/coalesced/index.d.ts +13 -13
  2. package/agnostic/html/hyper-json/smart-tags/coalesced/index.js +13 -13
  3. package/agnostic/html/hyper-json/smart-tags/isolated/index.d.ts +3 -3
  4. package/agnostic/html/hyper-json/smart-tags/isolated/index.js +3 -3
  5. package/agnostic/html/index.d.ts +2 -2
  6. package/agnostic/html/index.js +2 -2
  7. package/agnostic/misc/index.d.ts +3 -3
  8. package/agnostic/misc/index.js +3 -3
  9. package/agnostic/misc/logs/index.d.ts +1 -1
  10. package/agnostic/misc/logs/index.js +1 -1
  11. package/agnostic/numbers/index.d.ts +1 -1
  12. package/agnostic/numbers/index.js +1 -1
  13. package/agnostic/optim/index.d.ts +1 -1
  14. package/agnostic/optim/index.js +1 -1
  15. package/agnostic/strings/index.d.ts +1 -1
  16. package/agnostic/strings/index.js +1 -1
  17. package/agnostic/time/index.d.ts +1 -1
  18. package/agnostic/time/index.js +1 -1
  19. package/components/Input/index.controlled.d.ts +1 -1
  20. package/components/JsonEditor/index.js +16 -14
  21. package/components/ListLoader/index.controlled.d.ts +78 -0
  22. package/components/ListLoader/index.controlled.js +99 -0
  23. package/components/ListLoader/index.d.ts +69 -0
  24. package/components/ListLoader/index.js +146 -0
  25. package/components/ListLoader/styles.module.css +0 -0
  26. package/components/ListLoader/utils.d.ts +8 -0
  27. package/components/ListLoader/utils.js +10 -0
  28. package/components/index.d.ts +3 -2
  29. package/components/index.js +3 -2
  30. package/components/public-classnames.d.ts +1 -0
  31. package/components/public-classnames.js +1 -0
  32. package/index.d.ts +1 -1
  33. package/index.js +1 -1
  34. package/node/@aws-s3/storage/file/index.d.ts +1 -1
  35. package/node/@aws-s3/storage/file/index.js +1 -1
  36. package/node/@google-cloud/storage/directory/index.d.ts +1 -1
  37. package/node/@google-cloud/storage/directory/index.js +1 -1
  38. package/node/@google-cloud/storage/index.d.ts +1 -1
  39. package/node/@google-cloud/storage/index.js +1 -1
  40. package/node/files/get-size/index.d.ts +56 -0
  41. package/node/files/get-size/index.js +167 -0
  42. package/node/files/get-size/index.test.d.ts +1 -0
  43. package/node/files/get-size/index.test.js +143 -0
  44. package/node/files/index.d.ts +1 -0
  45. package/node/files/index.js +1 -0
  46. package/node/images/index.d.ts +1 -1
  47. package/node/images/index.js +1 -1
  48. package/node/images/transform/operations/index.d.ts +2 -2
  49. package/node/images/transform/operations/index.js +2 -2
  50. package/node/index.d.ts +1 -1
  51. package/node/index.js +1 -1
  52. package/package.json +15 -1
@@ -0,0 +1,146 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { toError } from '../../agnostic/misc/cast/index.js';
4
+ import { clamp } from '../../agnostic/numbers/clamp/index.js';
5
+ import { ListLoaderControlled } from './index.controlled.js';
6
+ import { rangeBetween } from './utils.js';
7
+ /**
8
+ * Paginated list abstraction. Fetches the pages it is asked for, keeps them
9
+ * fresh, and drives a {@link ListLoaderControlled} with the result.
10
+ *
11
+ * @param props - Component properties.
12
+ * @see {@link Props}
13
+ * @see {@link ListLoaderControlled} for the rendered markup and CSS elements.
14
+ * @returns A {@link ListLoaderControlled} fed with the loaded pages.
15
+ *
16
+ * @remarks
17
+ * - In controlled mode (`pages` defined), the page set is entirely driven by the
18
+ * parent. `fillGaps` and `dropPagesFurtherThan` are inert, and load buttons only
19
+ * report through `onLoadPageClick`.
20
+ * - In uncontrolled mode, internal state is initialized from `defaultPage` and load
21
+ * buttons extend the set themselves. `onLoadPageClick` still fires, after the
22
+ * internal state has been updated.
23
+ * - A page removed from the effective set is dropped from memory, and a request
24
+ * still in flight for it is discarded on arrival rather than re-inserted.
25
+ * - No page outside `firstPagePos`–`lastPagePos` is ever fetched, whichever mode
26
+ * is in use.
27
+ */
28
+ export const ListLoader = ({ className, pages, defaultPage, fillGaps = true, dropPagesFurtherThan, autoLoadPrevWhenVisible, autoLoadNextWhenVisible, firstPagePos, lastPagePos, fetch, filter, display, getIdentifier, onLoadPageClick, staleAfterMs, onFetchSuccess, onFetchError, fetchRetriesNb = Infinity, fetchRetriesDelayMs = 1000 }) => {
29
+ const [itemsPages, setItemsPages] = useState(new Map());
30
+ const [loadingPages, setLoadingPages] = useState(new Set());
31
+ const [internalPages, setInternalPages] = useState([
32
+ clamp(defaultPage ?? firstPagePos, firstPagePos, lastPagePos)
33
+ ]);
34
+ const requestedPages = useRef(new Set());
35
+ // Single source of truth for what should be loaded. Memoized so the effects
36
+ // below keep a stable dependency when the page set has not actually changed.
37
+ const currentPages = useMemo(() => (pages ?? internalPages).filter(page => page >= firstPagePos && page <= lastPagePos), [pages, internalPages, firstPagePos, lastPagePos]);
38
+ const handleLoadClick = (pagePos) => {
39
+ if (pages === undefined) {
40
+ setInternalPages(curr => {
41
+ const next = curr.includes(pagePos) ? curr : [...curr, pagePos];
42
+ if (dropPagesFurtherThan === undefined)
43
+ return next;
44
+ const kept = next.filter(page => Math.abs(page - pagePos) <= dropPagesFurtherThan);
45
+ return kept.length === next.length ? next : kept;
46
+ });
47
+ }
48
+ onLoadPageClick?.(pagePos);
49
+ };
50
+ const storePage = (page, items) => setItemsPages(curr => {
51
+ const next = new Map(curr);
52
+ next.set(page, { loadedAt: new Date(), items });
53
+ return next;
54
+ });
55
+ const setPageLoading = (page, isLoading) => setLoadingPages(curr => {
56
+ const next = new Set(curr);
57
+ if (isLoading)
58
+ next.add(page);
59
+ else
60
+ next.delete(page);
61
+ return next;
62
+ });
63
+ const reportFetchError = (page, err) => {
64
+ const error = toError(err);
65
+ if (onFetchError !== undefined)
66
+ return onFetchError(page, error);
67
+ // eslint-disable-next-line no-console
68
+ console.warn(`ListLoader failed to fetch page ${page}`, error);
69
+ };
70
+ // Guards the whole load path at once: a page dropped meanwhile stops its
71
+ // pending retries and its stale reloads without any further bookkeeping.
72
+ const loadPage = (page, retriesLeft = fetchRetriesNb) => {
73
+ if (!requestedPages.current.has(page))
74
+ return;
75
+ setPageLoading(page, true);
76
+ void fetch(page)
77
+ .then(items => {
78
+ if (!requestedPages.current.has(page))
79
+ return;
80
+ storePage(page, items);
81
+ onFetchSuccess?.(page, items);
82
+ setPageLoading(page, false);
83
+ })
84
+ .catch((err) => {
85
+ reportFetchError(page, err);
86
+ if (retriesLeft <= 0)
87
+ return setPageLoading(page, false);
88
+ window.setTimeout(() => loadPage(page, retriesLeft - 1), fetchRetriesDelayMs);
89
+ });
90
+ };
91
+ // Fx. dep. `currentPages` - Drops everything held for pages no longer wanted.
92
+ // The updaters return `curr` untouched when nothing was removed, so an unstable
93
+ // `pages` prop cannot spin the render loop.
94
+ useEffect(() => {
95
+ Array.from(requestedPages.current)
96
+ .filter(page => !currentPages.includes(page))
97
+ .forEach(page => { requestedPages.current.delete(page); });
98
+ setItemsPages(curr => {
99
+ const next = new Map(Array.from(curr).filter(([page]) => currentPages.includes(page)));
100
+ return next.size === curr.size ? curr : next;
101
+ });
102
+ setLoadingPages(curr => {
103
+ const next = new Set(Array.from(curr).filter(page => currentPages.includes(page)));
104
+ return next.size === curr.size ? curr : next;
105
+ });
106
+ }, [currentPages]);
107
+ useEffect(() => {
108
+ if (pages !== undefined)
109
+ return;
110
+ if (!fillGaps)
111
+ return;
112
+ setInternalPages(curr => {
113
+ if (curr.length === 0)
114
+ return curr;
115
+ const missing = rangeBetween(Math.min(...curr), Math.max(...curr))
116
+ .filter(page => !curr.includes(page));
117
+ if (missing.length === 0)
118
+ return curr;
119
+ return [...curr, ...missing];
120
+ });
121
+ }, [currentPages, fillGaps, pages]);
122
+ useEffect(() => {
123
+ currentPages.forEach(page => {
124
+ if (requestedPages.current.has(page))
125
+ return;
126
+ requestedPages.current.add(page);
127
+ loadPage(page);
128
+ });
129
+ }, [currentPages, fetch]);
130
+ // Fx. dep. `itemsPages` - Reschedules one timeout per page on every store, each
131
+ // due from its own `loadedAt`. A refetch updates `loadedAt`, which re-runs this
132
+ // effect and keeps the cycle going.
133
+ useEffect(() => {
134
+ if (staleAfterMs === undefined)
135
+ return;
136
+ const timeouts = Array.from(itemsPages).map(([page, { loadedAt }]) => {
137
+ const dueInMs = Math.max(0, staleAfterMs - (Date.now() - loadedAt.getTime()));
138
+ return window.setTimeout(() => loadPage(page), dueInMs);
139
+ });
140
+ return () => timeouts.forEach(timeout => window.clearTimeout(timeout));
141
+ }, [itemsPages, staleAfterMs, fetch]);
142
+ const controlledItemsPages = new Map(Array
143
+ .from(itemsPages)
144
+ .map(([page, { items }]) => [page, items]));
145
+ return _jsx(ListLoaderControlled, { className: className, pages: currentPages, firstPagePos: firstPagePos, lastPagePos: lastPagePos, itemsPages: controlledItemsPages, filter: filter, display: display, getIdentifier: getIdentifier, loadingPages: Array.from(loadingPages), onLoadPageClick: handleLoadClick, autoLoadPrevWhenVisible: autoLoadPrevWhenVisible, autoLoadNextWhenVisible: autoLoadNextWhenVisible });
146
+ };
File without changes
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Lists every integer from `from` to `to`, both included.
3
+ *
4
+ * @param from - Lower bound.
5
+ * @param to - Upper bound. When lower than `from`, the range is empty.
6
+ * @returns The integers in ascending order.
7
+ */
8
+ export declare function rangeBetween(from: number, to: number): number[];
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Lists every integer from `from` to `to`, both included.
3
+ *
4
+ * @param from - Lower bound.
5
+ * @param to - Upper bound. When lower than `from`, the range is empty.
6
+ * @returns The integers in ascending order.
7
+ */
8
+ export function rangeBetween(from, to) {
9
+ return Array.from({ length: Math.max(0, to - from + 1) }, (_, pos) => from + pos);
10
+ }
@@ -1,7 +1,7 @@
1
1
  export * as beforeAfter from './BeforeAfter/index.js'
2
2
  export * as button from './Button/index.js'
3
- export * as clippable from './Clippable/index.js'
4
3
  export * as disclaimer from './Disclaimer/index.js'
4
+ export * as clippable from './Clippable/index.js'
5
5
  export * as drawer from './Drawer/index.js'
6
6
  export * as eventListener from './EventListener/index.js'
7
7
  export * as gallery from './Gallery/index.js'
@@ -10,10 +10,11 @@ export * as image from './Image/index.js'
10
10
  export * as input from './Input/index.js'
11
11
  export * as intersectionObserver from './IntersectionObserver/index.js'
12
12
  export * as jsonEditor from './JsonEditor/index.js'
13
+ export * as listLoader from './ListLoader/index.js'
13
14
  export * as overlayer from './Overlayer/index.js'
14
15
  export * as paginator from './Paginator/index.js'
15
- export * as scrllgngn from './Scrllgngn/index.js'
16
16
  export * as resizeObserver from './ResizeObserver/index.js'
17
+ export * as scrllgngn from './Scrllgngn/index.js'
17
18
  export * as scrollListener from './ScrollListener/index.js'
18
19
  export * as select from './Select/index.js'
19
20
  export * as sequencer from './Sequencer/index.js'
@@ -1,7 +1,7 @@
1
1
  export * as beforeAfter from './BeforeAfter/index.js'
2
2
  export * as button from './Button/index.js'
3
- export * as clippable from './Clippable/index.js'
4
3
  export * as disclaimer from './Disclaimer/index.js'
4
+ export * as clippable from './Clippable/index.js'
5
5
  export * as drawer from './Drawer/index.js'
6
6
  export * as eventListener from './EventListener/index.js'
7
7
  export * as gallery from './Gallery/index.js'
@@ -10,10 +10,11 @@ export * as image from './Image/index.js'
10
10
  export * as input from './Input/index.js'
11
11
  export * as intersectionObserver from './IntersectionObserver/index.js'
12
12
  export * as jsonEditor from './JsonEditor/index.js'
13
+ export * as listLoader from './ListLoader/index.js'
13
14
  export * as overlayer from './Overlayer/index.js'
14
15
  export * as paginator from './Paginator/index.js'
15
- export * as scrllgngn from './Scrllgngn/index.js'
16
16
  export * as resizeObserver from './ResizeObserver/index.js'
17
+ export * as scrllgngn from './Scrllgngn/index.js'
17
18
  export * as scrollListener from './ScrollListener/index.js'
18
19
  export * as select from './Select/index.js'
19
20
  export * as sequencer from './Sequencer/index.js'
@@ -10,6 +10,7 @@ export declare const image = "lm-image";
10
10
  export declare const input = "lm-input";
11
11
  export declare const intersectionObserver = "lm-intersection-observer";
12
12
  export declare const jsonEditor = "lm-json-editor";
13
+ export declare const listLoader = "lm-list-loader";
13
14
  export declare const overlayer = "lm-overlayer";
14
15
  export declare const paginator = "lm-paginator";
15
16
  export declare const resizeObserver = "lm-resize-observer";
@@ -10,6 +10,7 @@ export const image = 'lm-image';
10
10
  export const input = 'lm-input';
11
11
  export const intersectionObserver = 'lm-intersection-observer';
12
12
  export const jsonEditor = 'lm-json-editor';
13
+ export const listLoader = 'lm-list-loader';
13
14
  export const overlayer = 'lm-overlayer';
14
15
  export const paginator = 'lm-paginator';
15
16
  export const resizeObserver = 'lm-resize-observer';
package/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export * as agnostic from './agnostic/index.js'
2
1
  export * as components from './components/index.js'
3
2
  export * as node from './node/index.js'
3
+ export * as agnostic from './agnostic/index.js'
package/index.js CHANGED
@@ -1,3 +1,3 @@
1
- export * as agnostic from './agnostic/index.js'
2
1
  export * as components from './components/index.js'
3
2
  export * as node from './node/index.js'
3
+ export * as agnostic from './agnostic/index.js'
@@ -1,6 +1,6 @@
1
1
  export * as copy from './copy/index.js'
2
- export * as download from './download/index.js'
3
2
  export * as exists from './exists/index.js'
3
+ export * as download from './download/index.js'
4
4
  export * as move from './move/index.js'
5
5
  export * as remove from './remove/index.js'
6
6
  export * as stat from './stat/index.js'
@@ -1,6 +1,6 @@
1
1
  export * as copy from './copy/index.js'
2
- export * as download from './download/index.js'
3
2
  export * as exists from './exists/index.js'
3
+ export * as download from './download/index.js'
4
4
  export * as move from './move/index.js'
5
5
  export * as remove from './remove/index.js'
6
6
  export * as stat from './stat/index.js'
@@ -1,4 +1,4 @@
1
1
  export * as copyDir from './copy-dir/index.js'
2
2
  export * as list from './list/index.js'
3
- export * as removeDir from './remove-dir/index.js'
4
3
  export * as moveDir from './move-dir/index.js'
4
+ export * as removeDir from './remove-dir/index.js'
@@ -1,4 +1,4 @@
1
1
  export * as copyDir from './copy-dir/index.js'
2
2
  export * as list from './list/index.js'
3
- export * as removeDir from './remove-dir/index.js'
4
3
  export * as moveDir from './move-dir/index.js'
4
+ export * as removeDir from './remove-dir/index.js'
@@ -1,3 +1,3 @@
1
- export * as bucket from './bucket/index.js'
2
1
  export * as directory from './directory/index.js'
2
+ export * as bucket from './bucket/index.js'
3
3
  export * as file from './file/index.js'
@@ -1,3 +1,3 @@
1
- export * as bucket from './bucket/index.js'
2
1
  export * as directory from './directory/index.js'
2
+ export * as bucket from './bucket/index.js'
3
3
  export * as file from './file/index.js'
@@ -0,0 +1,56 @@
1
+ import { type PathLike } from 'node:fs';
2
+ import { Duration } from '../../../agnostic/time/duration/index.js';
3
+ /**
4
+ * How to react to a per-entry read error (a failing `lstat`/`readdir`):
5
+ * - `'throw'`: reject the whole computation (default).
6
+ * - `'skip'`: count the offending entry as 0 bytes and keep going.
7
+ * - a function: called with the error and the offending path, then the entry
8
+ * counts as 0 bytes.
9
+ */
10
+ export type OnError = 'throw' | 'skip' | ((error: unknown, path: string) => void);
11
+ export type GetSizeOptions = {
12
+ /** Maximum number of concurrent filesystem reads. Bounds file-descriptor usage on large trees. @default 32 */
13
+ concurrency?: number;
14
+ /** How to handle a per-entry read error. @default 'throw' */
15
+ onError?: OnError;
16
+ /** Abort the traversal; aborting rejects with the signal's reason. */
17
+ signal?: AbortSignal;
18
+ /** Abort the traversal after this delay. A `number` is milliseconds; a `Duration` is converted via `toMs()`. Combined with `signal` when both are given. */
19
+ timeoutMs?: number | Duration;
20
+ /** Maximum directory depth to descend; entries deeper than this are not counted. @default Infinity */
21
+ maxDepth?: number;
22
+ /** Follow symbolic links instead of counting the link itself. Cycle-safe via inode tracking. @default false */
23
+ followSymlinks?: boolean;
24
+ /** Count a file reached through several hard links only once. @default false */
25
+ dedupeHardlinks?: boolean;
26
+ /** `'content'` = apparent size (`stat.size`); `'allocated'` = on-disk blocks (`stat.blocks × 512`). @default 'content' */
27
+ sizeOf?: 'content' | 'allocated';
28
+ };
29
+ export declare const defaultGetSizeOptions: {
30
+ concurrency: number;
31
+ onError: "throw";
32
+ maxDepth: number;
33
+ followSymlinks: false;
34
+ dedupeHardlinks: false;
35
+ sizeOf: "content";
36
+ };
37
+ /**
38
+ * Computes the total size, in bytes, of a file or directory.
39
+ *
40
+ * For a regular file, returns its own size. For a directory, returns the summed
41
+ * size of everything it contains, recursively.
42
+ *
43
+ * @param target - Path to a file or directory (`string`, `Buffer`, or a `file:` URL).
44
+ * @param [options] - Optional configuration. See `GetSizeOptions`.
45
+ * @returns The total size in bytes.
46
+ *
47
+ * @remarks
48
+ * By default symbolic links are not followed (each link counts as its own small
49
+ * size) and hard links are counted once per link. Enable `followSymlinks` to
50
+ * resolve links — cycles and repeated targets are made safe by tracking visited
51
+ * inodes — and `dedupeHardlinks` to count a shared inode only once.
52
+ *
53
+ * @throws If a path cannot be read and `onError` is `'throw'` (the default), or if
54
+ * `options.signal` is aborted.
55
+ */
56
+ export declare function getSize(target: PathLike, options?: GetSizeOptions): Promise<number>;
@@ -0,0 +1,167 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import path from 'node:path';
4
+ import { Duration } from '../../../agnostic/time/duration/index.js';
5
+ export const defaultGetSizeOptions = {
6
+ concurrency: 32,
7
+ onError: 'throw',
8
+ maxDepth: Infinity,
9
+ followSymlinks: false,
10
+ dedupeHardlinks: false,
11
+ sizeOf: 'content'
12
+ };
13
+ /**
14
+ * Computes the total size, in bytes, of a file or directory.
15
+ *
16
+ * For a regular file, returns its own size. For a directory, returns the summed
17
+ * size of everything it contains, recursively.
18
+ *
19
+ * @param target - Path to a file or directory (`string`, `Buffer`, or a `file:` URL).
20
+ * @param [options] - Optional configuration. See `GetSizeOptions`.
21
+ * @returns The total size in bytes.
22
+ *
23
+ * @remarks
24
+ * By default symbolic links are not followed (each link counts as its own small
25
+ * size) and hard links are counted once per link. Enable `followSymlinks` to
26
+ * resolve links — cycles and repeated targets are made safe by tracking visited
27
+ * inodes — and `dedupeHardlinks` to count a shared inode only once.
28
+ *
29
+ * @throws If a path cannot be read and `onError` is `'throw'` (the default), or if
30
+ * `options.signal` is aborted.
31
+ */
32
+ export async function getSize(target, options = {}) {
33
+ const concurrency = Math.max(1, Math.floor(options.concurrency ?? defaultGetSizeOptions.concurrency));
34
+ const timeoutMs = options.timeoutMs instanceof Duration ? options.timeoutMs.toMs() : options.timeoutMs;
35
+ const timeout = timeoutMs === undefined ? null : createTimeoutSignal(timeoutMs, options.signal);
36
+ const resolved = {
37
+ onError: options.onError ?? defaultGetSizeOptions.onError,
38
+ signal: timeout?.signal ?? options.signal,
39
+ limit: createLimiter(concurrency),
40
+ maxDepth: options.maxDepth ?? defaultGetSizeOptions.maxDepth,
41
+ followSymlinks: options.followSymlinks ?? defaultGetSizeOptions.followSymlinks,
42
+ dedupeHardlinks: options.dedupeHardlinks ?? defaultGetSizeOptions.dedupeHardlinks,
43
+ sizeOf: options.sizeOf ?? defaultGetSizeOptions.sizeOf,
44
+ visited: new Set()
45
+ };
46
+ try {
47
+ return await computeSize(toPathString(target), resolved, 0);
48
+ }
49
+ finally {
50
+ timeout?.dispose();
51
+ }
52
+ }
53
+ // Builds an abort signal that fires after `timeoutMs`, merged with a caller
54
+ // signal when provided. Aborting either aborts the returned one. `dispose`
55
+ // clears the timer and detaches the listener.
56
+ function createTimeoutSignal(timeoutMs, userSignal) {
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(() => { controller.abort(new Error(`getSize timed out after ${timeoutMs} ms`)); }, Math.max(0, timeoutMs));
59
+ timer.unref();
60
+ if (userSignal === undefined) {
61
+ return { signal: controller.signal, dispose: () => { clearTimeout(timer); } };
62
+ }
63
+ if (userSignal.aborted)
64
+ controller.abort(userSignal.reason);
65
+ const onUserAbort = () => { controller.abort(userSignal.reason); };
66
+ userSignal.addEventListener('abort', onUserAbort, { once: true });
67
+ return {
68
+ signal: controller.signal,
69
+ dispose: () => {
70
+ clearTimeout(timer);
71
+ userSignal.removeEventListener('abort', onUserAbort);
72
+ }
73
+ };
74
+ }
75
+ function toPathString(target) {
76
+ if (typeof target === 'string')
77
+ return target;
78
+ if (Buffer.isBuffer(target))
79
+ return target.toString('utf-8');
80
+ return fileURLToPath(target);
81
+ }
82
+ function measure(stats, options) {
83
+ return options.sizeOf === 'allocated' ? stats.blocks * 512 : stats.size;
84
+ }
85
+ async function computeSize(target, options, depth) {
86
+ options.signal?.throwIfAborted();
87
+ let stats;
88
+ try {
89
+ stats = await options.limit(async () => await fs.lstat(target));
90
+ }
91
+ catch (error) {
92
+ return handleError(error, target, options);
93
+ }
94
+ // Resolve symlinks when asked; otherwise a link counts as its own size.
95
+ let realTarget = target;
96
+ if (stats.isSymbolicLink()) {
97
+ if (!options.followSymlinks)
98
+ return measure(stats, options);
99
+ try {
100
+ stats = await options.limit(async () => await fs.stat(target));
101
+ if (stats.isDirectory())
102
+ realTarget = await options.limit(async () => await fs.realpath(target));
103
+ }
104
+ catch (error) {
105
+ return handleError(error, target, options);
106
+ }
107
+ }
108
+ // Count each physical inode once when following links (cycle safety) or
109
+ // deduping hard links.
110
+ if (options.followSymlinks || options.dedupeHardlinks) {
111
+ const key = `${stats.dev}:${stats.ino}`;
112
+ if (options.visited.has(key))
113
+ return 0;
114
+ options.visited.add(key);
115
+ }
116
+ if (!stats.isDirectory())
117
+ return measure(stats, options);
118
+ if (depth >= options.maxDepth)
119
+ return 0;
120
+ let children;
121
+ try {
122
+ children = await options.limit(async () => await fs.readdir(realTarget));
123
+ }
124
+ catch (error) {
125
+ return handleError(error, realTarget, options);
126
+ }
127
+ const childSizes = await Promise.all(children.map(async (child) => await computeSize(path.join(realTarget, child), options, depth + 1)));
128
+ return childSizes.reduce((total, size) => total + size, 0);
129
+ }
130
+ function handleError(error, target, options) {
131
+ if (options.onError === 'throw')
132
+ throw error;
133
+ if (typeof options.onError === 'function')
134
+ options.onError(error, target);
135
+ return 0;
136
+ }
137
+ // Global concurrency limiter: at most `max` wrapped calls run at once. Wrapped
138
+ // calls must be leaf operations (single fs syscalls) that never await another
139
+ // wrapped call, so the recursive traversal cannot deadlock on slots.
140
+ function createLimiter(max) {
141
+ let active = 0;
142
+ const waiters = [];
143
+ const acquire = async () => {
144
+ if (active < max) {
145
+ active += 1;
146
+ return;
147
+ }
148
+ // Resumed by `release`, which hands over the slot without decrementing.
149
+ await new Promise(resolve => { waiters.push(resolve); });
150
+ };
151
+ const release = () => {
152
+ const resume = waiters.shift();
153
+ if (resume !== undefined)
154
+ resume();
155
+ else
156
+ active -= 1;
157
+ };
158
+ return async function limit(fn) {
159
+ await acquire();
160
+ try {
161
+ return await fn();
162
+ }
163
+ finally {
164
+ release();
165
+ }
166
+ };
167
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import { pathToFileURL } from 'node:url';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { getSize } from './index.js';
7
+ import { seconds } from '../../../agnostic/time/duration/index.js';
8
+ // Fixtures (all under the OS temp dir):
9
+ // root/ a.txt (10) + sub/b.txt (5) + sub/empty/
10
+ // wideRoot/ 60 x 1-byte files (exercises concurrency)
11
+ // linkRoot/ target.txt (7) + link -> target.txt (symlink to file)
12
+ // cycleRoot/ self -> cycleRoot (symlink cycle to its own dir)
13
+ // hlRoot/ original.txt (9) + hard.txt (hard link to original.txt)
14
+ let root;
15
+ let wideRoot;
16
+ let linkRoot;
17
+ let cycleRoot;
18
+ let hlRoot;
19
+ const aBytes = 10;
20
+ const bBytes = 5;
21
+ const wideFileCount = 60;
22
+ const targetBytes = 7;
23
+ const hardBytes = 9;
24
+ beforeAll(async () => {
25
+ root = await fs.mkdtemp(path.join(os.tmpdir(), 'lm-get-size-'));
26
+ await fs.writeFile(path.join(root, 'a.txt'), 'x'.repeat(aBytes));
27
+ await fs.mkdir(path.join(root, 'sub'));
28
+ await fs.writeFile(path.join(root, 'sub', 'b.txt'), 'y'.repeat(bBytes));
29
+ await fs.mkdir(path.join(root, 'sub', 'empty'));
30
+ wideRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'lm-get-size-wide-'));
31
+ await Promise.all([...Array(wideFileCount)].map(async (_, i) => await fs.writeFile(path.join(wideRoot, `f${i}.txt`), 'z')));
32
+ linkRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'lm-get-size-link-'));
33
+ await fs.writeFile(path.join(linkRoot, 'target.txt'), 't'.repeat(targetBytes));
34
+ await fs.symlink(path.join(linkRoot, 'target.txt'), path.join(linkRoot, 'link'));
35
+ cycleRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'lm-get-size-cycle-'));
36
+ await fs.symlink(cycleRoot, path.join(cycleRoot, 'self'));
37
+ hlRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'lm-get-size-hl-'));
38
+ await fs.writeFile(path.join(hlRoot, 'original.txt'), 'h'.repeat(hardBytes));
39
+ await fs.link(path.join(hlRoot, 'original.txt'), path.join(hlRoot, 'hard.txt'));
40
+ });
41
+ afterAll(async () => {
42
+ await Promise.all([root, wideRoot, linkRoot, cycleRoot, hlRoot].map(async (dir) => await fs.rm(dir, { recursive: true, force: true })));
43
+ });
44
+ describe('getSize', () => {
45
+ it('returns the size of a single file', async () => {
46
+ expect(await getSize(path.join(root, 'a.txt'))).toBe(aBytes);
47
+ });
48
+ it('sums the sizes of a directory recursively', async () => {
49
+ expect(await getSize(root)).toBe(aBytes + bBytes);
50
+ });
51
+ it('returns the size of a nested directory', async () => {
52
+ expect(await getSize(path.join(root, 'sub'))).toBe(bBytes);
53
+ });
54
+ it('returns 0 for an empty directory', async () => {
55
+ expect(await getSize(path.join(root, 'sub', 'empty'))).toBe(0);
56
+ });
57
+ describe('PathLike variants', () => {
58
+ it('accepts a Buffer path', async () => {
59
+ expect(await getSize(Buffer.from(path.join(root, 'a.txt')))).toBe(aBytes);
60
+ });
61
+ it('accepts a file: URL', async () => {
62
+ expect(await getSize(pathToFileURL(root))).toBe(aBytes + bBytes);
63
+ });
64
+ });
65
+ it('rejects when the path does not exist', async () => {
66
+ await expect(getSize(path.join(root, 'does-not-exist'))).rejects.toThrow();
67
+ });
68
+ describe('concurrency', () => {
69
+ it('computes the same total with concurrency: 1', async () => {
70
+ expect(await getSize(root, { concurrency: 1 })).toBe(aBytes + bBytes);
71
+ });
72
+ it('handles a wide directory under a low concurrency', async () => {
73
+ expect(await getSize(wideRoot, { concurrency: 4 })).toBe(wideFileCount);
74
+ });
75
+ });
76
+ describe('onError', () => {
77
+ it("counts a missing path as 0 when onError is 'skip'", async () => {
78
+ expect(await getSize(path.join(root, 'nope'), { onError: 'skip' })).toBe(0);
79
+ });
80
+ it('invokes the onError callback and skips the entry', async () => {
81
+ const seen = [];
82
+ const size = await getSize(path.join(root, 'nope'), {
83
+ onError: (_error, failedPath) => { seen.push(failedPath); }
84
+ });
85
+ expect(size).toBe(0);
86
+ expect(seen).toHaveLength(1);
87
+ expect(seen[0]).toBe(path.join(root, 'nope'));
88
+ });
89
+ });
90
+ describe('signal', () => {
91
+ it('rejects when the signal is already aborted', async () => {
92
+ await expect(getSize(root, { signal: AbortSignal.abort() })).rejects.toThrow();
93
+ });
94
+ });
95
+ describe('maxDepth', () => {
96
+ it('counts only the first level with maxDepth: 1', async () => {
97
+ // a.txt (depth 1) counted; sub/ is a dir at depth 1, not descended.
98
+ expect(await getSize(root, { maxDepth: 1 })).toBe(aBytes);
99
+ });
100
+ it('descends one more level with maxDepth: 2', async () => {
101
+ expect(await getSize(root, { maxDepth: 2 })).toBe(aBytes + bBytes);
102
+ });
103
+ });
104
+ describe('followSymlinks', () => {
105
+ it('does not follow links by default (link counts as its own size)', async () => {
106
+ // target.txt (7) + the link's own (non-zero) size.
107
+ expect(await getSize(linkRoot)).toBeGreaterThan(targetBytes);
108
+ });
109
+ it('follows links and dedupes the shared target', async () => {
110
+ // The link resolves to target.txt, so the shared inode is counted once.
111
+ expect(await getSize(linkRoot, { followSymlinks: true })).toBe(targetBytes);
112
+ });
113
+ it('is cycle-safe when a link points back into the tree', async () => {
114
+ expect(await getSize(cycleRoot, { followSymlinks: true })).toBe(0);
115
+ });
116
+ });
117
+ describe('dedupeHardlinks', () => {
118
+ it('counts each hard link separately by default', async () => {
119
+ expect(await getSize(hlRoot)).toBe(hardBytes * 2);
120
+ });
121
+ it('counts a shared inode once when dedupeHardlinks is set', async () => {
122
+ expect(await getSize(hlRoot, { dedupeHardlinks: true })).toBe(hardBytes);
123
+ });
124
+ });
125
+ describe('sizeOf', () => {
126
+ it("'allocated' is at least the apparent content size", async () => {
127
+ const content = await getSize(root);
128
+ const allocated = await getSize(root, { sizeOf: 'allocated' });
129
+ expect(allocated).toBeGreaterThanOrEqual(content);
130
+ });
131
+ });
132
+ describe('timeoutMs', () => {
133
+ it('completes normally within a generous timeout (number)', async () => {
134
+ expect(await getSize(root, { timeoutMs: 10_000 })).toBe(aBytes + bBytes);
135
+ });
136
+ it('accepts a Duration', async () => {
137
+ expect(await getSize(root, { timeoutMs: seconds(10) })).toBe(aBytes + bBytes);
138
+ });
139
+ it('still honours an already-aborted signal when combined with a timeout', async () => {
140
+ await expect(getSize(root, { timeoutMs: 10_000, signal: AbortSignal.abort() })).rejects.toThrow();
141
+ });
142
+ });
143
+ });