@uxf/scripts 11.122.5 → 11.124.0

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 (36) hide show
  1. package/README.md +1 -38
  2. package/package.json +12 -16
  3. package/src/cli-args.js +43 -0
  4. package/src/cli-args.test.js +61 -0
  5. package/src/{GitLab.js → gitlab.js} +43 -21
  6. package/src/gitlab.test.js +207 -0
  7. package/src/{GoogleChat.js → google-chat.js} +10 -12
  8. package/src/google-chat.test.js +95 -0
  9. package/src/http.js +86 -0
  10. package/src/http.test.js +155 -0
  11. package/src/sitemap.js +80 -0
  12. package/src/sitemap.test.js +64 -0
  13. package/src/{Slack.js → slack.js} +9 -8
  14. package/src/slack.test.js +81 -0
  15. package/src/uxf-i18n-namespaces-gen/dependency-tree.js +102 -0
  16. package/src/uxf-i18n-namespaces-gen/dependency-tree.test.js +46 -0
  17. package/src/uxf-i18n-namespaces-gen/index.js +62 -44
  18. package/src/uxf-i18n-namespaces-gen/index.test.js +32 -1
  19. package/src/uxf-merge-requests-notifier/cli.js +9 -12
  20. package/src/uxf-merge-requests-notifier/index.js +44 -10
  21. package/src/uxf-merge-requests-notifier/index.test.js +103 -0
  22. package/src/uxf-push-notifier/cli.js +14 -13
  23. package/src/uxf-push-notifier/index.js +32 -23
  24. package/src/uxf-release/index.js +3 -3
  25. package/src/uxf-sitemap-check/index.js +6 -2
  26. package/src/uxf-sitemap-check/index.test.js +2 -2
  27. package/src/uxf-sitemap-meta-export/index.js +3 -3
  28. package/bin/uxf-lunch.js +0 -8
  29. package/bin/uxf-unused.js +0 -9
  30. package/src/Logger.js +0 -12
  31. package/src/Sitemap.js +0 -60
  32. package/src/shared/load-page-imports.js +0 -60
  33. package/src/uxf-lunch/cli.js +0 -44
  34. package/src/uxf-lunch/index.js +0 -50
  35. package/src/uxf-unused/cli.js +0 -30
  36. package/src/uxf-unused/index.js +0 -59
@@ -0,0 +1,102 @@
1
+ const { execFileSync } = require("child_process");
2
+ const path = require("path");
3
+
4
+ // 256 MB. `rev-dep`'s own `bin.js` wrapper shells out with `execSync` and no `maxBuffer`, so it
5
+ // silently truncates at Node's 1 MB default and still exits 0 — we resolve the platform binary
6
+ // ourselves to avoid that.
7
+ const MAX_BUFFER = 256 * 1024 * 1024;
8
+
9
+ function resolveRevDepBinary() {
10
+ const suffix = process.platform === "win32" ? ".exe" : "";
11
+ const request = `@rev-dep/${process.platform}-${process.arch}/bin/rev-dep${suffix}`;
12
+
13
+ try {
14
+ return require.resolve(request, { paths: [__dirname] });
15
+ } catch {
16
+ throw new Error(
17
+ `Could not locate the rev-dep binary for your platform (${request}). ` +
18
+ `It ships as an optionalDependency of rev-dep — reinstall without --omit=optional.`,
19
+ );
20
+ }
21
+ }
22
+
23
+ function runRevDep(cwd, tsConfig) {
24
+ const args = ["debug", "get-tree-for-cwd", "--cwd", cwd];
25
+
26
+ if (tsConfig) {
27
+ args.push("--tsconfig-json", tsConfig);
28
+ }
29
+
30
+ try {
31
+ return execFileSync(resolveRevDepBinary(), args, { encoding: "utf8", maxBuffer: MAX_BUFFER });
32
+ } catch (error) {
33
+ const details = error.stderr ? `\n${error.stderr}` : "";
34
+ throw new Error(`rev-dep failed to build the dependency tree for ${cwd}.${details}`);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Turns rev-dep's tree into the shape madge's `res.obj()` returned, so the callers' traversal
40
+ * keeps working unchanged:
41
+ *
42
+ * { "<cwd-relative file>": ["<cwd-relative dep>" | "<bare specifier>", ...] }
43
+ *
44
+ * Imports that rev-dep could not resolve to a project file (node modules, unresolved requests)
45
+ * are kept as their raw specifier — callers resolve those themselves via `require.resolve`,
46
+ * which is how `node_modules/@uxf/**` gets picked up.
47
+ *
48
+ * @param {string} cwd
49
+ * @param {{ tsConfig?: string, fileExtensions?: string[], include?: string[] }} options
50
+ * @returns {Record<string, string[]>}
51
+ */
52
+ function buildDependencyTree(cwd, options = {}) {
53
+ const { tsConfig, fileExtensions, include } = options;
54
+
55
+ const graph = JSON.parse(runRevDep(cwd, tsConfig));
56
+
57
+ const toRelative = (filePath) => (path.isAbsolute(filePath) ? path.relative(cwd, filePath) : filePath);
58
+
59
+ const extensions = fileExtensions?.map((extension) => (extension.startsWith(".") ? extension : `.${extension}`));
60
+
61
+ // `node_modules` is deliberately left to the callers' own resolution — rev-dep does not
62
+ // traverse into it, and it is the one place where the raw specifiers above are needed.
63
+ const isOutOfScope = (relativePath) =>
64
+ relativePath.startsWith("node_modules") || relativePath.startsWith("..") || path.isAbsolute(relativePath);
65
+
66
+ // `include` used to be madge's `excludeRegExp`: keep only files under the given prefixes.
67
+ const isIncluded = (relativePath) => !include?.length || include.some((prefix) => relativePath.startsWith(prefix));
68
+
69
+ const hasAllowedExtension = (relativePath) =>
70
+ !extensions || extensions.some((extension) => relativePath.endsWith(extension));
71
+
72
+ const isProjectFile = (relativePath) =>
73
+ !isOutOfScope(relativePath) && isIncluded(relativePath) && hasAllowedExtension(relativePath);
74
+
75
+ const tree = {};
76
+
77
+ for (const [file, dependencies] of Object.entries(graph)) {
78
+ const relativeFile = toRelative(file);
79
+
80
+ if (!isProjectFile(relativeFile)) {
81
+ continue;
82
+ }
83
+
84
+ tree[relativeFile] = (dependencies ?? [])
85
+ .map((dependency) => {
86
+ if (dependency.resolvedTypeLabel !== "UserModule" || !dependency.id) {
87
+ // node module or unresolved — hand the specifier over untouched
88
+ return dependency.request;
89
+ }
90
+
91
+ const relativeDependency = toRelative(dependency.id);
92
+
93
+ return isProjectFile(relativeDependency) ? relativeDependency : undefined;
94
+ })
95
+ .filter((dependency) => typeof dependency === "string" && dependency.length > 0);
96
+ }
97
+
98
+ return tree;
99
+ }
100
+
101
+ module.exports = buildDependencyTree;
102
+ module.exports.resolveRevDepBinary = resolveRevDepBinary;
@@ -0,0 +1,46 @@
1
+ /** @jest-environment node */
2
+ const path = require("path");
3
+ const buildDependencyTree = require("./dependency-tree");
4
+
5
+ const FIXTURE_CWD = path.join(__dirname, "tests");
6
+
7
+ describe("buildDependencyTree", () => {
8
+ const tree = buildDependencyTree(FIXTURE_CWD, { fileExtensions: ["ts", "tsx"] });
9
+
10
+ it("keys the tree by cwd-relative paths", () => {
11
+ expect(Object.keys(tree)).toEqual(expect.arrayContaining(["pages/page-a.tsx", "utils.tsx"]));
12
+ });
13
+
14
+ it("resolves relative imports to project files", () => {
15
+ expect(tree["pages/page-a.tsx"]).toEqual(
16
+ expect.arrayContaining(["components/no-index-file.tsx", "components/with-index-file/index.ts"]),
17
+ );
18
+ });
19
+
20
+ it("resolves a directory import to its index file", () => {
21
+ expect(tree["components/with-index-file/index.ts"]).toEqual(["components/with-index-file/with-index-file.tsx"]);
22
+ });
23
+
24
+ it("keeps unresolved node module imports as raw specifiers", () => {
25
+ // callers resolve these themselves — that is how node_modules/@uxf/** gets picked up
26
+ expect(tree["components/with-index-file/with-index-file.tsx"]).toEqual(
27
+ expect.arrayContaining(["@uxf/ui/chip", "@uxf/core/utils/noop"]),
28
+ );
29
+ });
30
+
31
+ it("does not emit node_modules files as tree keys", () => {
32
+ expect(Object.keys(tree).filter((file) => file.startsWith("node_modules"))).toEqual([]);
33
+ });
34
+
35
+ it("honours the include filter", () => {
36
+ const filtered = buildDependencyTree(FIXTURE_CWD, { fileExtensions: ["ts", "tsx"], include: ["pages"] });
37
+
38
+ expect(Object.keys(filtered).every((file) => file.startsWith("pages"))).toBe(true);
39
+ });
40
+
41
+ it("honours the fileExtensions filter", () => {
42
+ const filtered = buildDependencyTree(FIXTURE_CWD, { fileExtensions: ["ts"] });
43
+
44
+ expect(Object.keys(filtered).every((file) => file.endsWith(".ts"))).toBe(true);
45
+ });
46
+ });
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const madge = require("madge");
2
+ const buildDependencyTree = require("./dependency-tree");
3
3
  const path = require("path");
4
4
  const { readFileSync, readdirSync, writeFileSync, statSync, existsSync } = require("fs");
5
5
  const { findTFunctionNamespaces } = require("./utils/find-t-function-namespaces");
@@ -8,7 +8,8 @@ const join = require("node:path").join;
8
8
 
9
9
  const UXF_PACKAGES_PATH = "node_modules/@uxf";
10
10
  const FILE_EXTENSIONS = ["js", "mjs", "cjs", "ts", "tsx", "d.ts", "mts", "cts", "d.mts", "d.cts"];
11
- const TEXT_FILE_EXTENSIONS = new Set(FILE_EXTENSIONS.map((e) => (e.startsWith(".") ? e : "." + e)));
11
+ const DOTTED_FILE_EXTENSIONS = FILE_EXTENSIONS.map((e) => (e.startsWith(".") ? e : "." + e));
12
+ const TEXT_FILE_EXTENSIONS = new Set(DOTTED_FILE_EXTENSIONS);
12
13
  const TS_CONFIG_PATH = path.resolve(process.cwd(), "tsconfig.json");
13
14
  const TS_CONFIG = existsSync(TS_CONFIG_PATH) ? TS_CONFIG_PATH : undefined;
14
15
 
@@ -44,6 +45,38 @@ function isAllowedFile(file) {
44
45
  return !file.includes("node_modules") || file.includes(UXF_PACKAGES_PATH);
45
46
  }
46
47
 
48
+ /**
49
+ * Resolve an extension-less path the way TypeScript would: as a file with one of the known
50
+ * extensions appended, or as a directory holding an index file.
51
+ * @param {string} basePath
52
+ * @returns {string | undefined}
53
+ */
54
+ function resolveFileCandidate(basePath) {
55
+ for (const extension of DOTTED_FILE_EXTENSIONS) {
56
+ const candidate = basePath + extension;
57
+
58
+ if (existsSync(candidate)) {
59
+ return candidate;
60
+ }
61
+ }
62
+
63
+ if (existsSync(basePath)) {
64
+ if (statSync(basePath).isFile()) {
65
+ return basePath;
66
+ }
67
+
68
+ for (const extension of DOTTED_FILE_EXTENSIONS) {
69
+ const candidate = path.join(basePath, "index" + extension);
70
+
71
+ if (existsSync(candidate)) {
72
+ return candidate;
73
+ }
74
+ }
75
+ }
76
+
77
+ return undefined;
78
+ }
79
+
47
80
  /**
48
81
  * Resolve a module specifier (bare, relative, or absolute) to a real filesystem path if possible.
49
82
  * Falls back to the original specifier when not resolvable.
@@ -71,13 +104,9 @@ function resolveModuleSpecifier(spec) {
71
104
  for (const target of targets) {
72
105
  const mapped = target.includes("*") ? target.replace("*", middle) : target;
73
106
 
74
- const candidates = [
75
- path.resolve(ts.baseUrl, mapped),
76
- ...FILE_EXTENSIONS.map((e) => path.resolve(ts.baseUrl, mapped + e)),
77
- ...FILE_EXTENSIONS.map((e) => path.join(ts.baseUrl, mapped, "index" + e)),
78
- ].find(existsSync);
107
+ const candidate = resolveFileCandidate(path.resolve(ts.baseUrl, mapped));
79
108
 
80
- if (candidates) return candidates;
109
+ if (candidate) return candidate;
81
110
  }
82
111
  }
83
112
  } else if (spec === pattern) {
@@ -87,8 +116,19 @@ function resolveModuleSpecifier(spec) {
87
116
  }
88
117
  }
89
118
 
90
- const resolved = require.resolve(spec, { paths: [process.cwd()] });
91
- return isAllowedFile(resolved) ? resolved : spec;
119
+ try {
120
+ const resolved = require.resolve(spec, { paths: [process.cwd()] });
121
+ if (isAllowedFile(resolved)) return resolved;
122
+ } catch {
123
+ // not resolvable by Node — fall through to the TypeScript probe below
124
+ }
125
+
126
+ // Node's resolver only knows .js, so packages published (or symlinked by a workspace) as
127
+ // TypeScript source are invisible to it. Probe node_modules directly for those.
128
+ const candidate = resolveFileCandidate(path.resolve(process.cwd(), "node_modules", spec));
129
+ if (candidate && isAllowedFile(candidate)) return candidate;
130
+
131
+ return spec;
92
132
  } catch {
93
133
  return spec;
94
134
  }
@@ -107,20 +147,9 @@ function resolveImportFrom(spec, fromFile) {
107
147
  // Relative path from the file's directory
108
148
  if (spec.startsWith(".")) {
109
149
  const baseDir = path.dirname(fromFile);
110
- let candidate = path.resolve(baseDir, spec);
111
150
 
112
- // Try as file
113
- for (const e of FILE_EXTENSIONS) {
114
- const c = candidate + e;
115
- if (existsSync(c)) return c;
116
- }
117
- // Try as directory index
118
- if (existsSync(candidate) && statSync(candidate).isDirectory()) {
119
- for (const e of FILE_EXTENSIONS) {
120
- const idx = path.join(candidate, "index" + e);
121
- if (existsSync(idx)) return idx;
122
- }
123
- }
151
+ const candidate = resolveFileCandidate(path.resolve(baseDir, spec));
152
+ if (candidate) return candidate;
124
153
 
125
154
  // Fallback to Node resolver with file dir as base
126
155
  const resolved = require.resolve(spec, { paths: [baseDir] });
@@ -191,26 +220,14 @@ const filePathToRoute = (filePath) => {
191
220
  return removeTrailingSlash(route);
192
221
  };
193
222
 
194
- async function getMadgeTree(entries, include) {
195
- // Build madge options; allow traversing UXF packages in node_modules.
196
- const options = {
223
+ function getDependencyTree(cwd, include) {
224
+ // Only project files come from here imports of node_modules/@uxf are handed over as raw
225
+ // specifiers and resolved by getFiles() below.
226
+ return buildDependencyTree(cwd, {
197
227
  tsConfig: TS_CONFIG,
198
228
  fileExtensions: FILE_EXTENSIONS,
199
- includeNpm: true,
200
- dependencyFilter: (dependency) => {
201
- return !dependency.includes("node_modules") || dependency.includes(UXF_PACKAGES_PATH);
202
- },
203
- };
204
-
205
- // If include is provided, limit traversal to those prefixes AND node_modules/@uxf.
206
- if (Array.isArray(include) && include.length > 0) {
207
- const allowedPrefixes = [...include, UXF_PACKAGES_PATH];
208
- const searchDirs = new RegExp(`^(?!(${allowedPrefixes.join("|")}))`, "i");
209
- options.excludeRegExp = [searchDirs];
210
- }
211
-
212
- const res = await madge(entries, options);
213
- return res.obj();
229
+ include,
230
+ });
214
231
  }
215
232
 
216
233
  async function getFiles(entryPoint, tree) {
@@ -243,7 +260,7 @@ async function getFiles(entryPoint, tree) {
243
260
  const queue = [];
244
261
  const visited = new Set();
245
262
 
246
- // Seed queue with initial files from madge tree
263
+ // Seed queue with initial files from the dependency tree
247
264
  for (const file of flattenFilesOnPath) {
248
265
  if (file && typeof file === "string") {
249
266
  const fsPath = resolveModuleSpecifier(file);
@@ -302,8 +319,8 @@ async function main(include, output, defaultNamespaces, pagesDirectory) {
302
319
 
303
320
  const pages = walk(pagesDirectory).flat(Number.POSITIVE_INFINITY);
304
321
 
305
- // Build a global dependency tree rooted at project to leverage madge resolution across aliases/packages
306
- const tree = await getMadgeTree(process.cwd(), include);
322
+ // Build a global dependency tree rooted at project to leverage rev-dep resolution across aliases/packages
323
+ const tree = getDependencyTree(process.cwd(), include);
307
324
 
308
325
  for (const entryPoint of pages) {
309
326
  let namespaces = [];
@@ -346,6 +363,7 @@ async function main(include, output, defaultNamespaces, pagesDirectory) {
346
363
 
347
364
  module.exports = main;
348
365
  module.exports.isAllowedFile = isAllowedFile;
366
+ module.exports.resolveFileCandidate = resolveFileCandidate;
349
367
  module.exports.removeTrailingSlash = removeTrailingSlash;
350
368
  module.exports.filePathToRoute = filePathToRoute;
351
369
  module.exports.findNamespaces = findNamespaces;
@@ -1,5 +1,36 @@
1
1
  /** @jest-environment node */
2
- const { isAllowedFile, removeTrailingSlash, filePathToRoute, findNamespaces } = require("./index");
2
+ const path = require("path");
3
+ const {
4
+ isAllowedFile,
5
+ removeTrailingSlash,
6
+ filePathToRoute,
7
+ findNamespaces,
8
+ resolveFileCandidate,
9
+ } = require("./index");
10
+
11
+ const FIXTURES = path.join(__dirname, "tests");
12
+
13
+ describe("resolveFileCandidate", () => {
14
+ it("appends the extension to an extension-less path", () => {
15
+ expect(resolveFileCandidate(path.join(FIXTURES, "utils"))).toBe(path.join(FIXTURES, "utils.tsx"));
16
+ });
17
+
18
+ it("resolves a directory to its index file", () => {
19
+ expect(resolveFileCandidate(path.join(FIXTURES, "components/with-index-file"))).toBe(
20
+ path.join(FIXTURES, "components/with-index-file/index.ts"),
21
+ );
22
+ });
23
+
24
+ it("returns a path that already points at a file unchanged", () => {
25
+ const file = path.join(FIXTURES, "utils.tsx");
26
+
27
+ expect(resolveFileCandidate(file)).toBe(file);
28
+ });
29
+
30
+ it("returns undefined when nothing matches", () => {
31
+ expect(resolveFileCandidate(path.join(FIXTURES, "does-not-exist"))).toBeUndefined();
32
+ });
33
+ });
3
34
 
4
35
  describe("isAllowedFile", () => {
5
36
  it("allows .ts files outside node_modules", () => {
@@ -1,27 +1,24 @@
1
1
  const { argv, env } = require("process");
2
+ const { hasFlag } = require("../cli-args");
2
3
 
3
4
  const AVAILABLE_VARIANTS = ["CR", "STALE"];
4
5
 
5
- module.exports = async () => {
6
- const cli = require("yargs")()
7
- .command("$0", "UXF merge requests notifier", (yargs) => {
8
- yargs.demandCommand(0, 0).usage(`Usage:
6
+ const USAGE = `Usage:
9
7
  uxf-merge-requests-notifier [options]
10
8
 
9
+ Options:
10
+ -h, --help zobrazí tuto nápovědu
11
+
11
12
  Environment variables:
12
13
  VARIANT - optional - CR (default), STALE
13
14
  GITLAB_TOKEN - required
14
15
  GOOGLE_WEBHOOK_URL - required
15
- CI_SERVER_URL - required - setting by GitLab CI`);
16
- })
17
- .option("h", { alias: "help", group: "Options" })
18
- .strict(false)
19
- .exitProcess(false);
16
+ CI_SERVER_URL - required - setting by GitLab CI`;
20
17
 
18
+ module.exports = async () => {
21
19
  try {
22
- const { help, ...options } = cli.parse(argv.slice(2));
23
-
24
- if (Boolean(help)) {
20
+ if (hasFlag(argv.slice(2), ["-h", "--help"])) {
21
+ console.log(USAGE);
25
22
  return 0;
26
23
  }
27
24
 
@@ -1,13 +1,42 @@
1
- const GitLab = require("../GitLab");
2
- const GoogleChat = require("../GoogleChat");
3
- const relativeTime = require("dayjs/plugin/relativeTime");
4
- const dayjs = require("dayjs");
5
- require("dayjs/locale/cs");
6
- dayjs.locale("cs");
7
- dayjs.extend(relativeTime);
1
+ const GitLab = require("../gitlab");
2
+ const GoogleChat = require("../google-chat");
8
3
 
4
+ const DAY_IN_MS = 24 * 60 * 60 * 1000;
5
+
6
+ // `numeric: "always"` drží číselný tvar ("před 2 dny") tak, jak ho dělal dayjs.
7
+ // S "auto" by Intl střídalo slovní tvary ("předevčírem") jen pro některé hodnoty.
8
+ const RELATIVE_TIME = new Intl.RelativeTimeFormat("cs", { numeric: "always" });
9
+
10
+ /** @type {Array<[Intl.RelativeTimeFormatUnit, number]>} */
11
+ const RELATIVE_TIME_UNITS = [
12
+ ["year", 365 * 86400],
13
+ ["month", 30 * 86400],
14
+ ["day", 86400],
15
+ ["hour", 3600],
16
+ ["minute", 60],
17
+ ["second", 1],
18
+ ];
19
+
20
+ /** Náhrada `dayjs(date).fromNow()` s českou lokalizací. */
21
+ function fromNow(date) {
22
+ const diffInSeconds = (new Date(date).getTime() - Date.now()) / 1000;
23
+ const absolute = Math.abs(diffInSeconds);
24
+
25
+ for (const [unit, seconds] of RELATIVE_TIME_UNITS) {
26
+ if (absolute >= seconds || unit === "second") {
27
+ return RELATIVE_TIME.format(Math.round(diffInSeconds / seconds), unit);
28
+ }
29
+ }
30
+ }
31
+
32
+ /** Náhrada `dayjs().diff(date, "days")` — ořezává k nule stejně jako dayjs. */
33
+ function daysSince(date) {
34
+ return Math.trunc((Date.now() - new Date(date).getTime()) / DAY_IN_MS);
35
+ }
36
+
37
+ /** Česká shoda čísla se jménem: 1 → word1, 2–4 → word2, jinak (včetně 0) → word3. */
9
38
  function inflect(value, word1, word2, word3) {
10
- return `${value} ${value === 1 ? word1 : value <= 4 ? word2 : word3}`;
39
+ return `${value} ${value === 1 ? word1 : value > 1 && value <= 4 ? word2 : word3}`;
11
40
  }
12
41
 
13
42
  function mapMergeRequests(mrs, projects) {
@@ -52,7 +81,7 @@ module.exports = async function run(variant) {
52
81
  result = result.filter((mr) => mr.reviewers.length === 0);
53
82
  break;
54
83
  case "STALE":
55
- result = result.filter((mr) => dayjs().diff(mr.updatedAt, "days") > 30);
84
+ result = result.filter((mr) => daysSince(mr.updatedAt) > 30);
56
85
  break;
57
86
  }
58
87
 
@@ -75,7 +104,7 @@ module.exports = async function run(variant) {
75
104
  card: {
76
105
  header: {
77
106
  title: `${mr.project.name} | ${mr.title}`,
78
- subtitle: `${mr.author.name} | ${dayjs(mr.updatedAt).fromNow()} | ${inflect(
107
+ subtitle: `${mr.author.name} | ${fromNow(mr.updatedAt)} | ${inflect(
79
108
  changesCount,
80
109
  "soubor",
81
110
  "soubory",
@@ -98,3 +127,8 @@ module.exports = async function run(variant) {
98
127
  ]);
99
128
  }
100
129
  };
130
+
131
+ // Vystaveno kvůli testům — stejný vzorec jako v uxf-release/index.js.
132
+ module.exports.fromNow = fromNow;
133
+ module.exports.daysSince = daysSince;
134
+ module.exports.inflect = inflect;
@@ -0,0 +1,103 @@
1
+ const { fromNow, daysSince, inflect } = require("./index");
2
+
3
+ const NOW = new Date("2026-06-15T12:00:00.000Z").getTime();
4
+
5
+ function ago(milliseconds) {
6
+ return new Date(NOW - milliseconds).toISOString();
7
+ }
8
+
9
+ const SECOND = 1000;
10
+ const MINUTE = 60 * SECOND;
11
+ const HOUR = 60 * MINUTE;
12
+ const DAY = 24 * HOUR;
13
+
14
+ beforeEach(() => {
15
+ jest.spyOn(Date, "now").mockReturnValue(NOW);
16
+ });
17
+
18
+ afterEach(() => {
19
+ jest.restoreAllMocks();
20
+ });
21
+
22
+ // `fromNow` a `daysSince` nahradily dayjs (`.fromNow()` a `.diff(x, "days")`),
23
+ // proto na ně testy — jsou to jediná místa, kde se formátování času mohlo rozejít.
24
+ describe("fromNow", () => {
25
+ it("formats seconds in Czech", () => {
26
+ expect(fromNow(ago(30 * SECOND))).toBe("před 30 sekundami");
27
+ });
28
+
29
+ it("formats minutes", () => {
30
+ expect(fromNow(ago(5 * MINUTE))).toBe("před 5 minutami");
31
+ });
32
+
33
+ it("formats hours", () => {
34
+ expect(fromNow(ago(3 * HOUR))).toBe("před 3 hodinami");
35
+ });
36
+
37
+ it("formats days numerically rather than as a word", () => {
38
+ // Se `numeric: "auto"` by Intl vrátilo "předevčírem", což dayjs nedělal.
39
+ expect(fromNow(ago(2 * DAY))).toBe("před 2 dny");
40
+ });
41
+
42
+ it("formats months", () => {
43
+ expect(fromNow(ago(40 * DAY))).toBe("před 1 měsícem");
44
+ });
45
+
46
+ it("formats years", () => {
47
+ expect(fromNow(ago(400 * DAY))).toBe("před 1 rokem");
48
+ });
49
+
50
+ it("picks the largest fitting unit", () => {
51
+ expect(fromNow(ago(23 * HOUR))).toBe("před 23 hodinami");
52
+ expect(fromNow(ago(25 * HOUR))).toBe("před 1 dnem");
53
+ });
54
+
55
+ it("handles a future timestamp", () => {
56
+ expect(fromNow(new Date(NOW + 2 * DAY).toISOString())).toBe("za 2 dny");
57
+ });
58
+ });
59
+
60
+ describe("daysSince", () => {
61
+ it("counts whole days", () => {
62
+ expect(daysSince(ago(3 * DAY))).toBe(3);
63
+ });
64
+
65
+ it("truncates a partial day, the way dayjs diff did", () => {
66
+ expect(daysSince(ago(3 * DAY + 23 * HOUR))).toBe(3);
67
+ });
68
+
69
+ it("returns 0 for something that happened moments ago", () => {
70
+ expect(daysSince(ago(MINUTE))).toBe(0);
71
+ });
72
+
73
+ it("crosses the 30 day threshold used by the STALE variant", () => {
74
+ expect(daysSince(ago(30 * DAY))).toBe(30);
75
+ expect(daysSince(ago(31 * DAY))).toBe(31);
76
+ });
77
+ });
78
+
79
+ describe("inflect", () => {
80
+ it("uses the singular for one", () => {
81
+ expect(inflect(1, "soubor", "soubory", "souborů")).toBe("1 soubor");
82
+ });
83
+
84
+ it("uses the 2-4 form", () => {
85
+ expect(inflect(2, "soubor", "soubory", "souborů")).toBe("2 soubory");
86
+ expect(inflect(4, "soubor", "soubory", "souborů")).toBe("4 soubory");
87
+ });
88
+
89
+ it("uses the plural genitive from five up", () => {
90
+ expect(inflect(5, "soubor", "soubory", "souborů")).toBe("5 souborů");
91
+ expect(inflect(12, "soubor", "soubory", "souborů")).toBe("12 souborů");
92
+ });
93
+
94
+ // Regrese: původní podmínka `value <= 4` posílala nulu do tvaru pro 2–4
95
+ // ("0 soubory"). Čeština má pro nulu genitiv plurálu.
96
+ it("uses the plural genitive for zero", () => {
97
+ expect(inflect(0, "soubor", "soubory", "souborů")).toBe("0 souborů");
98
+ });
99
+
100
+ it("uses the plural genitive for a value that is not a number", () => {
101
+ expect(inflect(NaN, "soubor", "soubory", "souborů")).toBe("NaN souborů");
102
+ });
103
+ });
@@ -1,27 +1,28 @@
1
- const { argv, env } = require("process");
1
+ const { argv } = require("process");
2
+ const { hasFlag, readOption } = require("../cli-args");
2
3
 
3
- module.exports = async () => {
4
- const cli = require("yargs")()
5
- .command("$0", "UXF push notifier", (yargs) => {
6
- yargs.demandCommand(0, 0).usage(`Usage:
4
+ const USAGE = `Usage:
7
5
  uxf-push-notifier [options]
8
6
 
7
+ Options:
8
+ -g, --google-chat-webhook-url URL Google Chat webhooku
9
+ -h, --help zobrazí tuto nápovědu
10
+
9
11
  Environment variables:
10
12
  GITLAB_TOKEN - required
11
- `);
12
- })
13
- .option("g", { alias: "google-chat-webhook-url", group: "Options" })
14
- .option("h", { alias: "help", group: "Options" })
15
- .strict(false)
16
- .exitProcess(false);
13
+ `;
17
14
 
15
+ module.exports = async () => {
18
16
  try {
19
- const { help, g: googleChatWebhookUrl } = cli.parse(argv.slice(2));
17
+ const args = argv.slice(2);
20
18
 
21
- if (Boolean(help)) {
19
+ if (hasFlag(args, ["-h", "--help"])) {
20
+ console.log(USAGE);
22
21
  return 0;
23
22
  }
24
23
 
24
+ const googleChatWebhookUrl = readOption(args, ["-g", "--google-chat-webhook-url"]);
25
+
25
26
  await require("./index")(googleChatWebhookUrl);
26
27
  } catch (e) {
27
28
  console.error(e);