vite-userscript-plugin 2.1.1 → 2.2.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.
package/README.md CHANGED
@@ -133,13 +133,12 @@ See [examples/sourcemap](./examples/sourcemap).
133
133
  | Option | Default | Description |
134
134
  | --- | --- | --- |
135
135
  | `entry` | — | Userscript entry. Required. |
136
- | `header` | — | Metablock. Required: `name`, `version`, `match`. |
136
+ | `header` | — | Metablock. Required: `name`, `version`, `match`. Relative `icon` / `require` / `resource` / `supportURL` / `updateURL` / `downloadURL` join `homepage` (`homepageURL` / `website` / `source`). Absolute `http(s):` URLs stay as-is. |
137
137
  | `fileName` | sanitized `header.name` | Output base name (`{fileName}.user.js`). |
138
138
  | `server.open` | `false` | Open the install target when Vite starts. HMR: `.dev.user.js`. `file`: `.user.js` URL. |
139
139
  | `server.prefix` | `'server:'` | Prefix for `@name` in serve mode. `false` disables it. |
140
140
  | `server.file` | `false` | Watch-build `{fileName}.user.js` + `{fileName}.js` + `{fileName}.proxy.user.js`. Install `.user.js` (or the proxy if `file://` `@require` works). No HMR. |
141
- | `cssInject` | `'auto'` | How production CSS is injected. `'auto'` uses `GM_addStyle` or a `<style>` node. |
142
- | `align` | `1` | Extra spaces after the longest `@key`. `false` — one space. |
141
+ | `headerAlign` | `1` | Extra spaces after the longest `@key`. `false` one space. |
143
142
  | `generate` | — | Rewrite the generated metablock. |
144
143
  | `autoMetaUrls` | `false` | Fill empty `updateURL` / `downloadURL` from `homepage` / `homepageURL` / `website` / `source`. |
145
144
  | `metaFile` | `true` | Emit `{fileName}.meta.js`. |
@@ -181,6 +180,8 @@ In serve mode the header lists every grant. In production the plugin scans the b
181
180
 
182
181
  > [!NOTE]
183
182
  > Userscripts run on someone else’s origin. Import the file so Vite inlines it. `public/` only works for the `index.html` app on the Vite origin.
183
+ >
184
+ > `@icon`, `@require`, and `@resource` are different: the manager fetches them from the metablock URL, not from the `match` site. Write `icon: 'greasify.svg'` plus `homepage` (GitHub Pages, etc.) — the plugin joins them. Leave `https://…` as-is.
184
185
 
185
186
  ### `@run-at document-start` feels late in dev
186
187
 
@@ -204,6 +205,8 @@ In serve mode the header lists every grant. In production the plugin scans the b
204
205
  | Vite 3–7 | Vite 8 |
205
206
  | `scripts` + shared `header` | `userscript([config, config, …])` |
206
207
  | `ScriptOptions` | removed |
208
+ | `cssInject` | removed; imported CSS always appends a `<style>` node |
209
+ | `align` | `headerAlign` |
207
210
 
208
211
  ## License
209
212
 
package/dist/index.d.ts CHANGED
@@ -173,7 +173,6 @@ interface HeaderGenerateContext {
173
173
  userscript: string;
174
174
  mode: HeaderMode;
175
175
  }
176
- type CssInject = 'auto' | string | ((css: string) => void);
177
176
  /**
178
177
  * One userscript. Pass an object, or an array of these, to {@link UserscriptPluginConfig}.
179
178
  */
@@ -196,18 +195,12 @@ interface UserscriptConfig {
196
195
  * Serve-mode options.
197
196
  */
198
197
  server?: ServerConfig;
199
- /**
200
- * How to inject collected CSS in production builds.
201
- *
202
- * @default 'auto'
203
- */
204
- cssInject?: CssInject;
205
198
  /**
206
199
  * Extra spaces after the longest `@key`, or `false` for a single space.
207
200
  *
208
201
  * @default 1
209
202
  */
210
- align?: number | false;
203
+ headerAlign?: number | false;
211
204
  /**
212
205
  * Rewrite the generated metablock.
213
206
  */
@@ -240,32 +233,13 @@ interface ResolvedScript {
240
233
  prefix: string | false;
241
234
  file: boolean;
242
235
  };
243
- cssInject: CssInject;
244
- align: number | false;
236
+ headerAlign: number | false;
245
237
  generate?: (ctx: HeaderGenerateContext) => string;
246
238
  autoMetaUrls: boolean;
247
239
  metaFile: boolean;
248
240
  }
249
241
  //#endregion
250
- //#region src/header.d.ts
251
- interface HeaderOptions {
252
- align?: number | false;
253
- autoMetaUrls?: boolean;
254
- fileName?: string;
255
- generate?: (ctx: HeaderGenerateContext) => string;
256
- mode?: HeaderMode;
257
- }
258
- declare function resolveHomePage(header: HeaderConfig): string | undefined;
259
- declare function resolvePublicFileUrl(header: HeaderConfig, fileName: string): string | undefined;
260
- declare function generateHeader(config: HeaderConfig, options?: HeaderOptions): string;
261
- declare class Header {
262
- private readonly config;
263
- private readonly options;
264
- constructor(config: HeaderConfig, options?: HeaderOptions);
265
- generate(): string;
266
- }
267
- //#endregion
268
242
  //#region src/plugin.d.ts
269
243
  declare function UserscriptPlugin(config: UserscriptPluginConfig): Plugin[];
270
244
  //#endregion
271
- export { type CssInject, Header, type HeaderConfig, type HeaderGenerateContext, type ResolvedScript, type ServerConfig, type UserscriptConfig, type UserscriptPluginConfig, UserscriptPlugin as default, generateHeader, resolveHomePage, resolvePublicFileUrl };
245
+ export { type HeaderConfig, type HeaderGenerateContext, type ResolvedScript, type ServerConfig, type UserscriptConfig, type UserscriptPluginConfig, UserscriptPlugin as default };
package/dist/index.js CHANGED
@@ -5,6 +5,88 @@ import { pathToFileURL } from "node:url";
5
5
  import { existsSync, readFileSync } from "node:fs";
6
6
  import { styleText } from "node:util";
7
7
  import openLink from "open";
8
+ //#region src/grants/catalog.ts
9
+ const GM = [
10
+ "setValue",
11
+ "getValue",
12
+ "deleteValue",
13
+ "listValues",
14
+ "setValues",
15
+ "getValues",
16
+ "deleteValues",
17
+ "setClipboard",
18
+ "addStyle",
19
+ "addElement",
20
+ "addValueChangeListener",
21
+ "removeValueChangeListener",
22
+ "registerMenuCommand",
23
+ "unregisterMenuCommand",
24
+ "download",
25
+ "getTab",
26
+ "getTabs",
27
+ "saveTab",
28
+ "openInTab",
29
+ "notification",
30
+ "getResourceURL",
31
+ "getResourceText",
32
+ "xmlhttpRequest",
33
+ "webRequest",
34
+ "cookie",
35
+ "audio",
36
+ "log",
37
+ "info"
38
+ ];
39
+ const GMwindow = [
40
+ "unsafeWindow",
41
+ "window.onurlchange",
42
+ "window.focus",
43
+ "window.close"
44
+ ];
45
+ const GM_DOT_ALIASES = ["GM.xmlHttpRequest", "GM.getResourceUrl"];
46
+ const grants = [
47
+ ...GM.flatMap((grant) => [`GM_${grant}`, `GM.${grant}`]),
48
+ ...GMwindow,
49
+ ...GM_DOT_ALIASES
50
+ ];
51
+ const gmIdentifiers = [
52
+ "GM",
53
+ "unsafeWindow",
54
+ ...GM.map((grant) => `GM_${grant}`)
55
+ ];
56
+ //#endregion
57
+ //#region src/grants/scan.ts
58
+ const grantMatchers = grants.map((grant) => ({
59
+ grant,
60
+ pattern: new RegExp(`\\b${grant.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`)
61
+ }));
62
+ function removeDuplicates(arr) {
63
+ if (Array.isArray(arr)) return [...new Set(arr)];
64
+ return arr ? [arr] : [];
65
+ }
66
+ function defineGrants(code) {
67
+ return grantMatchers.filter(({ pattern }) => pattern.test(code)).map(({ grant }) => grant);
68
+ }
69
+ //#endregion
70
+ //#region src/grants/policy.ts
71
+ function withServeGrants(header) {
72
+ if (header.grant === "none") return header;
73
+ return {
74
+ ...header,
75
+ grant: [.../* @__PURE__ */ new Set([...header.grant ?? [], ...grants])]
76
+ };
77
+ }
78
+ function withBuildGrants(header, code, extraGrants = []) {
79
+ if (header.grant === "none") return header;
80
+ return {
81
+ ...header,
82
+ grant: removeDuplicates([
83
+ ...defineGrants(code),
84
+ ...removeDuplicates(header.grant),
85
+ ...extraGrants
86
+ ])
87
+ };
88
+ }
89
+ //#endregion
8
90
  //#region src/names.ts
9
91
  function sanitizeFileName(name) {
10
92
  return name.replace(/[<>:"/\\|?*\u0000-\u001F]+/g, "-").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "userscript";
@@ -16,6 +98,18 @@ function toIdentifier(name) {
16
98
  }
17
99
  //#endregion
18
100
  //#region src/header.ts
101
+ const ABSOLUTE_URL_RE = /^[a-z][a-z0-9+.-]*:/i;
102
+ const HOMEPAGE_RELATIVE_FIELDS = [
103
+ "icon",
104
+ "iconURL",
105
+ "defaulticon",
106
+ "icon64",
107
+ "icon64URL",
108
+ "require",
109
+ "supportURL",
110
+ "updateURL",
111
+ "downloadURL"
112
+ ];
19
113
  function ensureTrailingSlash(url) {
20
114
  return url.endsWith("/") ? url : `${url}/`;
21
115
  }
@@ -25,6 +119,29 @@ function resolveHomePage(header) {
25
119
  const trimmed = homePage.trim();
26
120
  return trimmed === "" ? void 0 : trimmed;
27
121
  }
122
+ function isResolvableHeaderPath(value) {
123
+ const trimmed = value.trim();
124
+ if (!trimmed) return false;
125
+ if (ABSOLUTE_URL_RE.test(trimmed) || trimmed.startsWith("//") || trimmed.startsWith("/")) return false;
126
+ return true;
127
+ }
128
+ function containsResolvableHeaderPath(value) {
129
+ if (typeof value === "string") return isResolvableHeaderPath(value);
130
+ if (!Array.isArray(value)) return false;
131
+ return value.some((item) => typeof item === "string" && isResolvableHeaderPath(item));
132
+ }
133
+ function containsResolvableResourcePath(resource) {
134
+ if (!Array.isArray(resource)) return false;
135
+ return resource.some((item) => {
136
+ return Array.isArray(item) && typeof item[1] === "string" && isResolvableHeaderPath(item[1]);
137
+ });
138
+ }
139
+ function listHomepageRelativeFields(header) {
140
+ const fields = [];
141
+ for (const key of HOMEPAGE_RELATIVE_FIELDS) if (containsResolvableHeaderPath(header[key])) fields.push(key);
142
+ if (containsResolvableResourcePath(header.resource)) fields.push("resource");
143
+ return fields;
144
+ }
28
145
  function resolvePublicFileUrl(header, fileName) {
29
146
  const homePage = resolveHomePage(header);
30
147
  if (!homePage) return;
@@ -32,6 +149,31 @@ function resolvePublicFileUrl(header, fileName) {
32
149
  return new URL(fileName, ensureTrailingSlash(homePage)).href;
33
150
  } catch {}
34
151
  }
152
+ function resolveHeaderUrlValue(header, value) {
153
+ if (typeof value === "string") {
154
+ if (!isResolvableHeaderPath(value)) return value;
155
+ return resolvePublicFileUrl(header, value.trim()) ?? value;
156
+ }
157
+ if (Array.isArray(value)) return value.map((item) => typeof item === "string" ? resolveHeaderUrlValue(header, item) : item);
158
+ return value;
159
+ }
160
+ function resolveResourceUrls(header, resource) {
161
+ if (!Array.isArray(resource)) return resource;
162
+ return resource.map((item) => {
163
+ if (!Array.isArray(item) || typeof item[1] !== "string") return item;
164
+ const [key, url] = item;
165
+ if (!isResolvableHeaderPath(url)) return item;
166
+ const resolved = resolvePublicFileUrl(header, url.trim());
167
+ return resolved ? [key, resolved] : item;
168
+ });
169
+ }
170
+ function applyHomepageRelativeUrls(header) {
171
+ if (!resolveHomePage(header)) return header;
172
+ const next = { ...header };
173
+ for (const key of HOMEPAGE_RELATIVE_FIELDS) if (next[key] != null) Object.assign(next, { [key]: resolveHeaderUrlValue(next, next[key]) });
174
+ if (next.resource != null) next.resource = resolveResourceUrls(next, next.resource);
175
+ return next;
176
+ }
35
177
  function applyAutoMetaUrls(header, fileName) {
36
178
  const updateURL = header.updateURL ?? resolvePublicFileUrl(header, `${fileName}.meta.js`);
37
179
  const downloadURL = header.downloadURL ?? resolvePublicFileUrl(header, `${fileName}.user.js`);
@@ -53,7 +195,8 @@ function formatValue(value) {
53
195
  }
54
196
  function generateHeader(config, options = {}) {
55
197
  const fileName = options.fileName ?? sanitizeFileName(config.name);
56
- const header = options.autoMetaUrls ? applyAutoMetaUrls({ ...config }, fileName) : { ...config };
198
+ const withRelativeUrls = applyHomepageRelativeUrls({ ...config });
199
+ const header = options.autoMetaUrls ? applyAutoMetaUrls(withRelativeUrls, fileName) : withRelativeUrls;
57
200
  const keys = Object.keys(header).filter((key) => {
58
201
  const value = header[key];
59
202
  return value !== void 0 && value !== null && value !== false;
@@ -88,99 +231,6 @@ function generateHeader(config, options = {}) {
88
231
  mode: options.mode ?? "build"
89
232
  });
90
233
  }
91
- var Header = class {
92
- config;
93
- options;
94
- constructor(config, options = {}) {
95
- this.config = config;
96
- this.options = options;
97
- }
98
- generate() {
99
- return generateHeader(this.config, this.options);
100
- }
101
- };
102
- //#endregion
103
- //#region src/grants/catalog.ts
104
- const GM = [
105
- "setValue",
106
- "getValue",
107
- "deleteValue",
108
- "listValues",
109
- "setValues",
110
- "getValues",
111
- "deleteValues",
112
- "setClipboard",
113
- "addStyle",
114
- "addElement",
115
- "addValueChangeListener",
116
- "removeValueChangeListener",
117
- "registerMenuCommand",
118
- "unregisterMenuCommand",
119
- "download",
120
- "getTab",
121
- "getTabs",
122
- "saveTab",
123
- "openInTab",
124
- "notification",
125
- "getResourceURL",
126
- "getResourceText",
127
- "xmlhttpRequest",
128
- "webRequest",
129
- "cookie",
130
- "audio",
131
- "log",
132
- "info"
133
- ];
134
- const GMwindow = [
135
- "unsafeWindow",
136
- "window.onurlchange",
137
- "window.focus",
138
- "window.close"
139
- ];
140
- const GM_DOT_ALIASES = ["GM.xmlHttpRequest", "GM.getResourceUrl"];
141
- const grants = [
142
- ...GM.flatMap((grant) => [`GM_${grant}`, `GM.${grant}`]),
143
- ...GMwindow,
144
- ...GM_DOT_ALIASES
145
- ];
146
- const gmIdentifiers = [
147
- "GM",
148
- "unsafeWindow",
149
- ...GM.map((grant) => `GM_${grant}`)
150
- ];
151
- //#endregion
152
- //#region src/grants/scan.ts
153
- const grantMatchers = grants.map((grant) => ({
154
- grant,
155
- pattern: new RegExp(`\\b${grant.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`)
156
- }));
157
- function removeDuplicates(arr) {
158
- if (Array.isArray(arr)) return [...new Set(arr)];
159
- return arr ? [arr] : [];
160
- }
161
- function defineGrants(code) {
162
- return grantMatchers.filter(({ pattern }) => pattern.test(code)).map(({ grant }) => grant);
163
- }
164
- //#endregion
165
- //#region src/grants/policy.ts
166
- function withServeGrants(header) {
167
- if (header.grant === "none") return header;
168
- return {
169
- ...header,
170
- grant: [.../* @__PURE__ */ new Set([...header.grant ?? [], ...grants])]
171
- };
172
- }
173
- function withBuildGrants(header, code, extraGrants = []) {
174
- if (header.grant === "none") return header;
175
- return {
176
- ...header,
177
- grant: removeDuplicates([
178
- ...defineGrants(code),
179
- ...removeDuplicates(header.grant),
180
- ...extraGrants
181
- ])
182
- };
183
- }
184
234
  //#endregion
185
235
  //#region src/sourcemap.ts
186
236
  function countHeaderLines(prefix) {
@@ -264,13 +314,9 @@ function isAsset(item) {
264
314
  //#endregion
265
315
  //#region src/build/css.ts
266
316
  const defaultCssInjector = `(function (css) {
267
- if (typeof GM_addStyle === 'function') {
268
- GM_addStyle(css)
269
- } else {
270
- var style = document.createElement('style')
271
- style.textContent = css
272
- ;(document.head || document.documentElement).appendChild(style)
273
- }
317
+ var style = document.createElement('style')
318
+ style.textContent = css
319
+ ;(document.head || document.documentElement).appendChild(style)
274
320
  })`;
275
321
  function collectImportedCss(chunk, bundle, seen = /* @__PURE__ */ new Set()) {
276
322
  const files = [...chunk.viteMetadata?.importedCss ?? []];
@@ -297,11 +343,8 @@ function collectCss(chunk, bundle) {
297
343
  files
298
344
  };
299
345
  }
300
- function createCssInject(css, cssInject = "auto") {
301
- const payload = JSON.stringify(css);
302
- if (cssInject === "auto") return `${defaultCssInjector}(${payload});\n`;
303
- if (typeof cssInject === "function") return `(${cssInject.toString()})(${payload});\n`;
304
- return `(${cssInject})(${payload});\n`;
346
+ function createCssInject(css) {
347
+ return `${defaultCssInjector}(${JSON.stringify(css)});\n`;
305
348
  }
306
349
  //#endregion
307
350
  //#region src/build/iife.ts
@@ -345,7 +388,7 @@ function createWatchProxyHeader(script, jsAbsPath) {
345
388
  }
346
389
  function generateWatchProxy(script, jsAbsPath) {
347
390
  return generateHeader(createWatchProxyHeader(script, jsAbsPath), {
348
- align: script.align,
391
+ align: script.headerAlign,
349
392
  autoMetaUrls: false,
350
393
  fileName: script.fileName,
351
394
  generate: script.generate,
@@ -407,9 +450,9 @@ function findScriptForChunk(chunk, fileName, scripts) {
407
450
  return scripts.find((script) => chunk.name === script.fileName || fileName === `${script.fileName}.js` || fileName === `${script.fileName}.user.js`);
408
451
  }
409
452
  function createHeadedUserscript(script, options) {
410
- const headerConfig = withBuildGrants(script.header, options.wrapped, options.extraGrants);
453
+ const headerConfig = withBuildGrants(script.header, options.wrapped);
411
454
  const prefix = `${generateHeader(headerConfig, {
412
- align: script.align,
455
+ align: script.headerAlign,
413
456
  autoMetaUrls: script.autoMetaUrls,
414
457
  fileName: script.fileName,
415
458
  generate: script.generate,
@@ -457,10 +500,9 @@ function applyUserscriptBundle(bundle, config, context) {
457
500
  const inlined = stripSourceMappingUrl(inlineImportedChunks(chunk, bundle));
458
501
  const { css, files: cssFiles } = collectCss(chunk, bundle);
459
502
  for (const cssFile of cssFiles) if (!keptCss.has(cssFile)) leftoverAssets.push(...withSourceMaps([cssFile]));
460
- const cssPrelude = css ? createCssInject(css, script.cssInject) : "";
503
+ const cssPrelude = css ? createCssInject(css) : "";
461
504
  const body = `${inlined}${chunk.code}`;
462
505
  const wrapped = ensureIife(body);
463
- const extraGrants = css && script.cssInject === "auto" ? ["GM_addStyle"] : [];
464
506
  const code = `${cssPrelude}${wrapped}`;
465
507
  const emitFileProxy = Boolean(context.emitProxy && context.outDir && script.server.file);
466
508
  leftoverAssets.push(`${fileName}.map`);
@@ -468,7 +510,6 @@ function applyUserscriptBundle(bundle, config, context) {
468
510
  body,
469
511
  code,
470
512
  cssPrelude,
471
- extraGrants,
472
513
  inlined,
473
514
  map: chunk.map,
474
515
  wrapped
@@ -491,7 +532,7 @@ function applyUserscriptBundle(bundle, config, context) {
491
532
  chunk.code = headed.code;
492
533
  chunk.fileName = `${script.fileName}.user.js`;
493
534
  if (script.metaFile) emitFile(`${script.fileName}.meta.js`, generateHeader(headed.headerConfig, {
494
- align: script.align,
535
+ align: script.headerAlign,
495
536
  autoMetaUrls: script.autoMetaUrls,
496
537
  fileName: script.fileName,
497
538
  generate: script.generate,
@@ -612,8 +653,7 @@ function toResolvedScript(config) {
612
653
  prefix: config.server?.prefix ?? "server:",
613
654
  file: config.server?.file ?? false
614
655
  },
615
- cssInject: config.cssInject ?? "auto",
616
- align: config.align ?? 1,
656
+ headerAlign: config.headerAlign ?? 1,
617
657
  generate: config.generate,
618
658
  autoMetaUrls: config.autoMetaUrls ?? false,
619
659
  metaFile: config.metaFile ?? true
@@ -628,6 +668,19 @@ function collectAutoMetaUrlsWarnings(config) {
628
668
  }
629
669
  return warnings;
630
670
  }
671
+ function collectHomepageRelativeUrlWarnings(config) {
672
+ const warnings = [];
673
+ for (const script of config.scripts) {
674
+ if (resolveHomePage(script.header)) continue;
675
+ const fields = listHomepageRelativeFields(script.header);
676
+ if (!fields.length) continue;
677
+ warnings.push(`[${PLUGIN_NAME}] "${script.fileName}" has relative header URLs (${fields.join(", ")}) but no homepage, homepageURL, website, or source`);
678
+ }
679
+ return warnings;
680
+ }
681
+ function collectConfigWarnings(config) {
682
+ return [...collectAutoMetaUrlsWarnings(config), ...collectHomepageRelativeUrlWarnings(config)];
683
+ }
631
684
  function resolvePluginConfig(config) {
632
685
  const items = Array.isArray(config) ? config : [config];
633
686
  if (!items.length) throw new Error(`[${PLUGIN_NAME}] Provide a userscript config or a non-empty array`);
@@ -813,7 +866,7 @@ function createDevUserscript(options) {
813
866
  root: options.root,
814
867
  prefix: options.script.server.prefix,
815
868
  headerOptions: {
816
- align: options.script.align,
869
+ align: options.script.headerAlign,
817
870
  autoMetaUrls: options.script.autoMetaUrls,
818
871
  generate: options.script.generate
819
872
  },
@@ -1029,7 +1082,7 @@ function UserscriptPlugin(config) {
1029
1082
  command = viteConfig.command;
1030
1083
  resolved = absolutizeEntries(resolved, viteConfig.root);
1031
1084
  reactPreamble = hasReactRefreshPlugin(viteConfig.plugins);
1032
- for (const message of collectAutoMetaUrlsWarnings(resolved)) viteConfig.logger.warn(message);
1085
+ for (const message of collectConfigWarnings(resolved)) viteConfig.logger.warn(message);
1033
1086
  }
1034
1087
  },
1035
1088
  {
@@ -1087,4 +1140,4 @@ function UserscriptPlugin(config) {
1087
1140
  ];
1088
1141
  }
1089
1142
  //#endregion
1090
- export { Header, UserscriptPlugin as default, generateHeader, resolveHomePage, resolvePublicFileUrl };
1143
+ export { UserscriptPlugin as default };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vite-userscript-plugin",
3
3
  "type": "module",
4
- "version": "2.1.1",
4
+ "version": "2.2.0",
5
5
  "author": {
6
6
  "name": "Vitalij Ryndin",
7
7
  "url": "https://github.com/crashmax-dev"