vite-userscript-plugin 2.1.0 → 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
@@ -83,7 +83,7 @@ Details: [types/README.md](./types/README.md).
83
83
 
84
84
  `vite` prints `/{fileName}.dev.user.js` — install that URL once. HMR covers code and styles.
85
85
 
86
- `server.file: true` skips HMR. The same `vite` watch-builds `{fileName}.js` (headerless IIFE) and `{fileName}.proxy.user.js` with `@require file://` to that IIFE. Install the printed `/{fileName}.proxy.user.js` URL. See [examples/serve-file](./examples/serve-file).
86
+ `server.file: true` skips HMR. The same `vite` watch-builds `{fileName}.user.js` (headed IIFE), `{fileName}.js`, and `{fileName}.proxy.user.js` (`@require file://` to the IIFE). Install the printed `/{fileName}.user.js` URL (Firefox-safe). The `Proxy` line is for managers that can poll `file://`. See [examples/serve-file](./examples/serve-file).
87
87
 
88
88
  > [!IMPORTANT]
89
89
  > Changing `@match`, `@grant`, or `@name` needs a reinstall.
@@ -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
- | `server.open` | `false` | Open the install target when Vite starts. HMR: `.dev.user.js`. `file`: `.proxy.user.js` URL. |
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
- | `server.file` | `false` | Watch-build `{fileName}.js` + `{fileName}.proxy.user.js` (`@require file://`). Install from the printed URL. 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. |
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
+ | `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`. |
@@ -161,7 +160,7 @@ In serve mode the header lists every grant. In production the plugin scans the b
161
160
  | [svelte](./examples/svelte) | SFC `<style>`. |
162
161
  | [multiple-entries](./examples/multiple-entries) | Two scripts. |
163
162
  | [sourcemap](./examples/sourcemap) | Inline map, HTML page, virtual module. |
164
- | [serve-file](./examples/serve-file) | `server.file`, install the proxy from the printed URL. |
163
+ | [serve-file](./examples/serve-file) | `server.file`, install `.user.js` or the proxy from the printed URLs. |
165
164
 
166
165
  ## FAQ
167
166
 
@@ -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
 
@@ -190,7 +191,7 @@ In serve mode the header lists every grant. In production the plugin scans the b
190
191
  ### `file://` `@require` is blocked
191
192
 
192
193
  > [!NOTE]
193
- > Tampermonkey must allow local file access (`@require` from `file://`). Violentmonkey polls the required file after you install `{fileName}.proxy.user.js` from the printed URL. HTTP `@require` and page auto-reload are not part of this mode.
194
+ > Firefox extensions cannot read `file://`. Install the printed `{fileName}.user.js` URL instead of the proxy. Tampermonkey on Chromium must allow local file access for the proxy `@require`. Violentmonkey can poll the local IIFE after you install the proxy.
194
195
 
195
196
  ## Migration from v1
196
197
 
@@ -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
@@ -147,7 +147,7 @@ type HeaderConfig = {
147
147
  interface ServerConfig {
148
148
  /**
149
149
  * Open the install target when Vite starts.
150
- * HMR: `.dev.user.js` URL. `file`: `{fileName}.proxy.user.js` URL.
150
+ * HMR: `.dev.user.js` URL. `file`: `{fileName}.user.js` URL.
151
151
  *
152
152
  * @default false
153
153
  */
@@ -160,9 +160,9 @@ interface ServerConfig {
160
160
  */
161
161
  prefix?: string | false;
162
162
  /**
163
- * Watch-build into `outDir`: headerless `{fileName}.js` and `{fileName}.proxy.user.js`
164
- * with `@require file://` pointing at the IIFE.
165
- * Install the proxy from the printed `/{fileName}.proxy.user.js` URL. No HMR for this script.
163
+ * Watch-build into `outDir`: `{fileName}.user.js`, headerless `{fileName}.js`,
164
+ * and `{fileName}.proxy.user.js` with `@require file://` pointing at the IIFE.
165
+ * Install `/{fileName}.user.js` (Firefox-safe) or the proxy URL. No HMR for this script.
166
166
  *
167
167
  * @default false
168
168
  */
@@ -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
@@ -2,9 +2,91 @@ import { basename, posix, relative, resolve, sep } from "node:path";
2
2
  import { build } from "vite";
3
3
  import { Buffer } from "node:buffer";
4
4
  import { pathToFileURL } from "node:url";
5
- import { existsSync } from "node:fs";
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,
@@ -406,6 +449,28 @@ function withSourceMaps(fileNames) {
406
449
  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
  }
452
+ function createHeadedUserscript(script, options) {
453
+ const headerConfig = withBuildGrants(script.header, options.wrapped);
454
+ const prefix = `${generateHeader(headerConfig, {
455
+ align: script.headerAlign,
456
+ autoMetaUrls: script.autoMetaUrls,
457
+ fileName: script.fileName,
458
+ generate: script.generate,
459
+ mode: "build"
460
+ })}\n\n`;
461
+ const nextFileName = `${script.fileName}.user.js`;
462
+ let nextCode = `${prefix}${options.code}`;
463
+ if (options.map) {
464
+ const wrapOffset = isAlreadyIife(stripSourceMappingUrl(options.body)) ? 0 : 1;
465
+ const lineOffset = countHeaderLines(prefix) + countHeaderLines(options.cssPrelude) + wrapOffset + countHeaderLines(options.inlined);
466
+ const map = stripVendorSourcesContent(offsetSourceMap(options.map, lineOffset, nextFileName));
467
+ nextCode = `${stripSourceMappingUrl(nextCode).replace(/\n+$/g, "\n")}//# sourceMappingURL=${toInlineSourceMappingUrl(map)}\n`;
468
+ }
469
+ return {
470
+ headerConfig,
471
+ code: nextCode.endsWith("\n") ? nextCode : `${nextCode}\n`
472
+ };
473
+ }
409
474
  function deleteBundleFiles(bundle, fileNames) {
410
475
  for (const fileName of fileNames) delete bundle[fileName];
411
476
  }
@@ -435,13 +500,20 @@ function applyUserscriptBundle(bundle, config, context) {
435
500
  const inlined = stripSourceMappingUrl(inlineImportedChunks(chunk, bundle));
436
501
  const { css, files: cssFiles } = collectCss(chunk, bundle);
437
502
  for (const cssFile of cssFiles) if (!keptCss.has(cssFile)) leftoverAssets.push(...withSourceMaps([cssFile]));
438
- const cssPrelude = css ? createCssInject(css, script.cssInject) : "";
503
+ const cssPrelude = css ? createCssInject(css) : "";
439
504
  const body = `${inlined}${chunk.code}`;
440
505
  const wrapped = ensureIife(body);
441
- const extraGrants = css && script.cssInject === "auto" ? ["GM_addStyle"] : [];
442
506
  const code = `${cssPrelude}${wrapped}`;
443
507
  const emitFileProxy = Boolean(context.emitProxy && context.outDir && script.server.file);
444
508
  leftoverAssets.push(`${fileName}.map`);
509
+ const headed = createHeadedUserscript(script, {
510
+ body,
511
+ code,
512
+ cssPrelude,
513
+ inlined,
514
+ map: chunk.map,
515
+ wrapped
516
+ });
445
517
  if (emitFileProxy) {
446
518
  const requireName = toRequireFileName(script.fileName);
447
519
  let nextCode = code.endsWith("\n") ? code : `${code}\n`;
@@ -454,28 +526,13 @@ function applyUserscriptBundle(bundle, config, context) {
454
526
  chunk.code = nextCode;
455
527
  chunk.fileName = requireName;
456
528
  emitFile(toProxyFileName(script.fileName), `${generateWatchProxy(script, resolve(context.outDir, requireName))}\n`);
529
+ emitFile(`${script.fileName}.user.js`, headed.code);
457
530
  continue;
458
531
  }
459
- const headerConfig = withBuildGrants(script.header, wrapped, extraGrants);
460
- const prefix = `${generateHeader(headerConfig, {
461
- align: script.align,
462
- autoMetaUrls: script.autoMetaUrls,
463
- fileName: script.fileName,
464
- generate: script.generate,
465
- mode: "build"
466
- })}\n\n`;
467
- const nextFileName = `${script.fileName}.user.js`;
468
- let nextCode = `${prefix}${code}`;
469
- if (chunk.map) {
470
- const wrapOffset = isAlreadyIife(stripSourceMappingUrl(body)) ? 0 : 1;
471
- const lineOffset = countHeaderLines(prefix) + countHeaderLines(cssPrelude) + wrapOffset + countHeaderLines(inlined);
472
- chunk.map = stripVendorSourcesContent(offsetSourceMap(chunk.map, lineOffset, nextFileName));
473
- nextCode = `${stripSourceMappingUrl(nextCode).replace(/\n+$/g, "\n")}//# sourceMappingURL=${toInlineSourceMappingUrl(chunk.map)}\n`;
474
- }
475
- chunk.code = nextCode;
476
- chunk.fileName = nextFileName;
477
- if (script.metaFile) emitFile(`${script.fileName}.meta.js`, generateHeader(headerConfig, {
478
- align: script.align,
532
+ chunk.code = headed.code;
533
+ chunk.fileName = `${script.fileName}.user.js`;
534
+ if (script.metaFile) emitFile(`${script.fileName}.meta.js`, generateHeader(headed.headerConfig, {
535
+ align: script.headerAlign,
479
536
  autoMetaUrls: script.autoMetaUrls,
480
537
  fileName: script.fileName,
481
538
  generate: script.generate,
@@ -495,7 +552,7 @@ function createClientSnapshot(scripts, command) {
495
552
  return scripts.map((script) => {
496
553
  let suffix = ".dev.user.js";
497
554
  if (command === "build") suffix = ".user.js";
498
- else if (script.server.file) suffix = ".proxy.user.js";
555
+ else if (script.server.file) suffix = ".user.js";
499
556
  return {
500
557
  name: script.header.name,
501
558
  version: script.header.version,
@@ -596,8 +653,7 @@ function toResolvedScript(config) {
596
653
  prefix: config.server?.prefix ?? "server:",
597
654
  file: config.server?.file ?? false
598
655
  },
599
- cssInject: config.cssInject ?? "auto",
600
- align: config.align ?? 1,
656
+ headerAlign: config.headerAlign ?? 1,
601
657
  generate: config.generate,
602
658
  autoMetaUrls: config.autoMetaUrls ?? false,
603
659
  metaFile: config.metaFile ?? true
@@ -612,6 +668,19 @@ function collectAutoMetaUrlsWarnings(config) {
612
668
  }
613
669
  return warnings;
614
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
+ }
615
684
  function resolvePluginConfig(config) {
616
685
  const items = Array.isArray(config) ? config : [config];
617
686
  if (!items.length) throw new Error(`[${PLUGIN_NAME}] Provide a userscript config or a non-empty array`);
@@ -648,9 +717,10 @@ function shimModule(code, id) {
648
717
  }
649
718
  //#endregion
650
719
  //#region src/serve/logger.ts
651
- function formatInstallLine(installUrl) {
720
+ function formatInstallLine(installUrl, label = "Userscript") {
652
721
  const coloredUrl = styleText("cyan", installUrl.replace(/:(\d+)\//, (_match, port) => `:${styleText("bold", port)}/`));
653
- return ` ${styleText("green", "➜")} ${styleText("bold", "Userscript")}: ${coloredUrl}`;
722
+ const padded = label.padEnd(10);
723
+ return ` ${styleText("green", "➜")} ${styleText("bold", padded)}: ${coloredUrl}`;
654
724
  }
655
725
  function formatRebuildLine(elapsedMs) {
656
726
  return `${styleText("green", "Userscript rebuilt")} ${styleText("dim", `(${elapsedMs}ms)`)}`;
@@ -720,9 +790,16 @@ function matchDevUserscript(url, fileName) {
720
790
  function matchProxyUserscript(url, fileName) {
721
791
  return (url.split("?")[0] ?? "") === `/${toProxyFileName(fileName)}`;
722
792
  }
723
- function toInstallUrl(origin, fileName, file = false) {
724
- const name = file ? toProxyFileName(fileName) : `${fileName}.dev.user.js`;
725
- return `${origin.replace(/\/$/, "")}/${name}`;
793
+ function matchFileUserscript(url, fileName) {
794
+ return (url.split("?")[0] ?? "") === `/${fileName}.user.js`;
795
+ }
796
+ function toInstallUrl(origin, fileName, kind = "dev") {
797
+ const names = {
798
+ dev: `${fileName}.dev.user.js`,
799
+ proxy: toProxyFileName(fileName),
800
+ user: `${fileName}.user.js`
801
+ };
802
+ return `${origin.replace(/\/$/, "")}/${names[kind]}`;
726
803
  }
727
804
  function toServeEntryPath(root, entry) {
728
805
  const absolute = resolve(root, entry);
@@ -779,6 +856,9 @@ function findDevScript(url, scripts) {
779
856
  function findProxyScript(url, scripts) {
780
857
  return scripts.find((script) => script.server.file && matchProxyUserscript(url, script.fileName));
781
858
  }
859
+ function findFileUserscript(url, scripts) {
860
+ return scripts.find((script) => script.server.file && matchFileUserscript(url, script.fileName));
861
+ }
782
862
  function createDevUserscript(options) {
783
863
  return generateDevUserscript({
784
864
  script: options.script,
@@ -786,7 +866,7 @@ function createDevUserscript(options) {
786
866
  root: options.root,
787
867
  prefix: options.script.server.prefix,
788
868
  headerOptions: {
789
- align: options.script.align,
869
+ align: options.script.headerAlign,
790
870
  autoMetaUrls: options.script.autoMetaUrls,
791
871
  generate: options.script.generate
792
872
  },
@@ -824,6 +904,17 @@ function configureDevServer(server, resolved, reactPreamble) {
824
904
  writeScript(res, createReactBootstrapModule(entry));
825
905
  return;
826
906
  }
907
+ const fileScript = findFileUserscript(url, resolved.scripts);
908
+ if (fileScript) {
909
+ const userJsPath = resolve(server.config.root, server.config.build.outDir, `${fileScript.fileName}.user.js`);
910
+ try {
911
+ writeScript(res, readFileSync(userJsPath, "utf8"));
912
+ } catch {
913
+ res.statusCode = 404;
914
+ res.end();
915
+ }
916
+ return;
917
+ }
827
918
  const proxyScript = findProxyScript(url, resolved.scripts);
828
919
  if (proxyScript) {
829
920
  writeScript(res, `${generateWatchProxy(proxyScript, resolve(server.config.root, server.config.build.outDir, toRequireFileName(proxyScript.fileName)))}\n`);
@@ -848,7 +939,14 @@ function configureDevServer(server, resolved, reactPreamble) {
848
939
  let origins = [];
849
940
  if (urls) origins = urls.local.length ? urls.local : urls.network;
850
941
  const printInstall = () => {
851
- for (const origin of origins) for (const script of resolved.scripts) info(formatInstallLine(toInstallUrl(origin, script.fileName, script.server.file)));
942
+ for (const origin of origins) for (const script of resolved.scripts) {
943
+ if (script.server.file) {
944
+ info(formatInstallLine(toInstallUrl(origin, script.fileName, "user")));
945
+ info(formatInstallLine(toInstallUrl(origin, script.fileName, "proxy"), "Proxy"));
946
+ continue;
947
+ }
948
+ info(formatInstallLine(toInstallUrl(origin, script.fileName)));
949
+ }
852
950
  info(formatFaqHint());
853
951
  };
854
952
  const logger = createAfterLocalLogger(info, urls?.local.length ?? 0, printInstall);
@@ -866,7 +964,7 @@ function configureDevServer(server, resolved, reactPreamble) {
866
964
  if (!toOpen.length) return;
867
965
  queueMicrotask(() => {
868
966
  const origin = resolveServerOrigin(server.resolvedUrls);
869
- for (const script of toOpen) openLink(toInstallUrl(origin, script.fileName, script.server.file));
967
+ for (const script of toOpen) openLink(toInstallUrl(origin, script.fileName, script.server.file ? "user" : "dev"));
870
968
  });
871
969
  });
872
970
  }
@@ -984,7 +1082,7 @@ function UserscriptPlugin(config) {
984
1082
  command = viteConfig.command;
985
1083
  resolved = absolutizeEntries(resolved, viteConfig.root);
986
1084
  reactPreamble = hasReactRefreshPlugin(viteConfig.plugins);
987
- for (const message of collectAutoMetaUrlsWarnings(resolved)) viteConfig.logger.warn(message);
1085
+ for (const message of collectConfigWarnings(resolved)) viteConfig.logger.warn(message);
988
1086
  }
989
1087
  },
990
1088
  {
@@ -1042,4 +1140,4 @@ function UserscriptPlugin(config) {
1042
1140
  ];
1043
1141
  }
1044
1142
  //#endregion
1045
- 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.0",
4
+ "version": "2.2.0",
5
5
  "author": {
6
6
  "name": "Vitalij Ryndin",
7
7
  "url": "https://github.com/crashmax-dev"