@gjsify/cli 0.16.0 → 0.16.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.
@@ -37,6 +37,14 @@ export declare class BuildAction {
37
37
  * there is no in-bundle plugin, so this hook is the sole injection point.
38
38
  */
39
39
  private applyShebang;
40
+ /**
41
+ * Post-bundle step for a `--app browser` HTML entry: write the processed
42
+ * `index.html` beside the JS bundle, with the entry `<script>`'s `src`
43
+ * rewritten to point at the built bundle. Mirrors `applyShebang` — kept in
44
+ * the CLI (not a Rolldown plugin) because the native rolldown engine
45
+ * exposes no `emitFile`.
46
+ */
47
+ private applyBrowserHtml;
40
48
  /**
41
49
  * GJS plugin loader — bundle the resolved plugin module to a single
42
50
  * self-contained ESM file, then import that. Works around GJS's native
@@ -4,11 +4,12 @@ import { isGjs } from '@gjsify/rolldown-plugin-gjsify/runtime';
4
4
  import { resolveUserPlugins } from '../utils/resolve-plugin-by-name.js';
5
5
  import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals, detectNodeGiGlobals, } from '@gjsify/rolldown-plugin-gjsify/globals';
6
6
  import { pnpPlugin } from '@gjsify/rolldown-plugin-pnp';
7
- import { dirname, extname, join } from 'node:path';
7
+ import { dirname, extname, join, resolve } from 'node:path';
8
8
  import { pathToFileURL } from 'node:url';
9
9
  import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
10
10
  import { normalizeBundlerOptions, mergeBundlerOptions } from '../utils/normalize-bundler-options.js';
11
11
  import { inputSourceDirs, isOutdirInsideSource, libraryOutputLeakError } from '../utils/library-output.js';
12
+ import { detectHtmlEntry, parseHtmlEntry, emitBrowserHtml, htmlOutPathFor } from '../utils/html-entry.js';
12
13
  const DEFAULT_GJS_SHEBANG = '#!/usr/bin/env -S gjs -m';
13
14
  /**
14
15
  * `true` when `path` points at a location that's unsafe to use as a build
@@ -241,6 +242,28 @@ export class BuildAction {
241
242
  if (verbose)
242
243
  console.debug(`[gjsify] --shebang: wrote ${line} + chmod 0o755 to ${outfile}`);
243
244
  }
245
+ /**
246
+ * Post-bundle step for a `--app browser` HTML entry: write the processed
247
+ * `index.html` beside the JS bundle, with the entry `<script>`'s `src`
248
+ * rewritten to point at the built bundle. Mirrors `applyShebang` — kept in
249
+ * the CLI (not a Rolldown plugin) because the native rolldown engine
250
+ * exposes no `emitFile`.
251
+ */
252
+ async applyBrowserHtml(htmlEntry, outfile, verbose) {
253
+ const jsOutPath = resolve(outfile);
254
+ const outHtmlPath = htmlOutPathFor(jsOutPath);
255
+ const html = emitBrowserHtml({
256
+ htmlSource: htmlEntry.htmlSource,
257
+ scriptTag: htmlEntry.scriptTag,
258
+ scriptSrc: htmlEntry.scriptSrc,
259
+ jsOutPath,
260
+ outHtmlPath,
261
+ });
262
+ await mkdir(dirname(outHtmlPath), { recursive: true });
263
+ await writeFile(outHtmlPath, html);
264
+ if (verbose)
265
+ console.debug(`[gjsify] --app browser: wrote ${outHtmlPath}`);
266
+ }
244
267
  /**
245
268
  * GJS plugin loader — bundle the resolved plugin module to a single
246
269
  * self-contained ESM file, then import that. Works around GJS's native
@@ -329,6 +352,23 @@ export class BuildAction {
329
352
  async buildApp(app = 'gjs', opts = {}) {
330
353
  const { verbose, typescript, exclude, library: pkg, aliases, excludeGlobals } = this.configData;
331
354
  const userBundler = normalizeBundlerOptions(this.configData);
355
+ // --- `--app browser` HTML entry (Vite-style) ---
356
+ // When the entry is an `index.html`, bundle the module its
357
+ // `<script type="module" src>` references (rewrite `input` to that
358
+ // module so auto-globals detection + the bundle both see the real
359
+ // entry, NOT the html) and remember the page so a post-bundle step can
360
+ // emit a processed `index.html` next to the JS. Guarded on browser +
361
+ // an html input; every other build path is untouched.
362
+ let htmlEntry = null;
363
+ if (app === 'browser') {
364
+ const htmlPath = detectHtmlEntry(userBundler.input);
365
+ if (htmlPath) {
366
+ const htmlSource = await readFile(htmlPath, 'utf-8');
367
+ const { moduleEntry, scriptTag, scriptSrc } = parseHtmlEntry(htmlPath, htmlSource);
368
+ userBundler.input = moduleEntry;
369
+ htmlEntry = { htmlSource, scriptTag, scriptSrc };
370
+ }
371
+ }
332
372
  const formatRaw = userBundler.output?.format ??
333
373
  (userBundler.output?.file?.endsWith('.cjs') ? 'cjs' : 'esm');
334
374
  // The orchestrator only handles esm/cjs (iife is not a GJS / Node /
@@ -346,6 +386,12 @@ export class BuildAction {
346
386
  }
347
387
  outfile = candidate;
348
388
  }
389
+ // An html entry emits its `index.html` next to the JS bundle, so it
390
+ // needs a concrete outfile to anchor that sibling path.
391
+ if (htmlEntry && !outfile) {
392
+ throw new Error('gjsify build: an .html entry needs an explicit --outfile ' +
393
+ '(e.g. --outfile dist-app/app.js); the emitted index.html is written beside it.');
394
+ }
349
395
  const { consoleShim, globals } = this.configData;
350
396
  const userExternal = Array.isArray(userBundler.external) ? userBundler.external : undefined;
351
397
  const userBanner = typeof userBundler.output?.banner === 'string' ? userBundler.output.banner : undefined;
@@ -485,6 +531,13 @@ export class BuildAction {
485
531
  if ((app === 'gjs' || app === 'node') && this.configData.shebang) {
486
532
  await this.applyShebang(app, outfile, verbose);
487
533
  }
534
+ // Browser HTML entry: emit the processed `index.html` beside the bundle
535
+ // (engine-portable post-step — the native rolldown engine has no
536
+ // `emitFile`, so this can't live in a plugin). `outfile` is guaranteed
537
+ // present here (guarded above when `htmlEntry` is set).
538
+ if (app === 'browser' && htmlEntry && outfile) {
539
+ await this.applyBrowserHtml(htmlEntry, outfile, verbose);
540
+ }
488
541
  return [writeResult];
489
542
  }
490
543
  /**
@@ -172,8 +172,18 @@ export async function packWorkspace(wsDir, opts = {}) {
172
172
  * Walk the workspace and return the list of files to include in the tarball,
173
173
  * relative to `wsDir`. Mirrors npm pack's selection rules:
174
174
  *
175
- * - If pkg.files exists: use it as an allowlist (with .npmignore as a
176
- * blacklist within those globs)
175
+ * - If pkg.files exists: the array is an ALLOWLIST that overrides
176
+ * .gitignore / .npmignore entirely. Every file under an allowlisted
177
+ * path/dir ships from disk; only the hardcoded implicit-exclusion set
178
+ * (node_modules, .git, lockfiles, … — applied by `walkAll` /
179
+ * `expandFilesPatterns`) and npm's always-ignore names are dropped.
180
+ * This matches `npm pack`: a `files` entry re-includes a path even when
181
+ * .gitignore/.npmignore would drop it (verified against `npm pack
182
+ * --dry-run`). Trimming `files`-allowlisted content by .gitignore
183
+ * silently dropped build-generated-but-allowlisted files like
184
+ * adwaita-web's `src/styles.generated.ts` (gitignored, allowlisted via
185
+ * `files: ["src"]`) — shipping a broken tarball whose `src/index.ts`
186
+ * imported a file the tarball didn't contain.
177
187
  * - Otherwise: walk everything, apply .npmignore + .gitignore, drop the
178
188
  * implicit-exclusion set (node_modules, .git, …)
179
189
  *
@@ -185,18 +195,22 @@ function collectFiles(wsDir, pkg) {
185
195
  const filesField = Array.isArray(pkg.files)
186
196
  ? pkg.files.filter((f) => typeof f === 'string')
187
197
  : null;
188
- let candidates;
198
+ const out = new Set();
189
199
  if (filesField) {
190
- candidates = expandFilesPatterns(wsDir, filesField);
200
+ // A `files` allowlist overrides .gitignore/.npmignore — ship every
201
+ // allowlisted path from disk. `expandFilesPatterns` already excludes
202
+ // the NEVER_INCLUDED_BASENAMES set (node_modules, .git, lockfiles, …)
203
+ // via `walkAll`, so junk is still kept out.
204
+ for (const f of expandFilesPatterns(wsDir, filesField))
205
+ out.add(f);
191
206
  }
192
207
  else {
193
- candidates = walkAll(wsDir);
194
- }
195
- const ignore = loadIgnore(wsDir);
196
- const out = new Set();
197
- for (const f of candidates) {
198
- if (!ignore(f))
199
- out.add(f);
208
+ // No `files` field — walk everything and apply .npmignore/.gitignore.
209
+ const ignore = loadIgnore(wsDir);
210
+ for (const f of walkAll(wsDir)) {
211
+ if (!ignore(f))
212
+ out.add(f);
213
+ }
200
214
  }
201
215
  for (const f of always) {
202
216
  if (existsSync(join(wsDir, f)))
@@ -0,0 +1,46 @@
1
+ import type { RolldownOptions } from 'rolldown';
2
+ export interface ParsedHtmlEntry {
3
+ /** Absolute path to the module the `<script type="module" src>` references. */
4
+ moduleEntry: string;
5
+ /** The full matched `<script …></script>`/`<script … />` tag text. */
6
+ scriptTag: string;
7
+ /** The raw `src` attribute value from that tag (as authored in the HTML). */
8
+ scriptSrc: string;
9
+ }
10
+ /**
11
+ * Return the `.html` path when `input` is a SINGLE html entry (a literal
12
+ * `.html` string, a 1-element array of one, or a 1-key record whose value is
13
+ * one) — else `null`. Multi-entry inputs and non-html entries (incl. globs
14
+ * like `src/**\/*.ts`) are never treated as html. The path is returned as
15
+ * authored (relative or absolute); the caller resolves + reads it.
16
+ */
17
+ export declare function detectHtmlEntry(input: RolldownOptions['input'] | undefined): string | null;
18
+ /**
19
+ * Find the FIRST `<script type="module" src="…">` whose `src` is a local
20
+ * (non-`http(s)://`, non-`//`) path and resolve the referenced module against
21
+ * the HTML file's directory. Throws when no such tag exists.
22
+ */
23
+ export declare function parseHtmlEntry(htmlPath: string, htmlSource: string): ParsedHtmlEntry;
24
+ export interface EmitBrowserHtmlInput {
25
+ /** The original HTML source. */
26
+ htmlSource: string;
27
+ /** The `<script>` tag matched by {@link parseHtmlEntry}. */
28
+ scriptTag: string;
29
+ /** That tag's original `src` value. */
30
+ scriptSrc: string;
31
+ /** Absolute path to the built JS bundle the emitted page should load. */
32
+ jsOutPath: string;
33
+ /** Absolute path the emitted HTML will be written to. */
34
+ outHtmlPath: string;
35
+ }
36
+ /**
37
+ * Produce the emitted HTML: the original source with the entry script's `src`
38
+ * rewritten to point at the built JS bundle, relative to the output HTML's own
39
+ * directory (so the page is portable regardless of where `dist/` is served
40
+ * from). Everything else — `<title>`, meta tags, other markup — is preserved.
41
+ */
42
+ export declare function emitBrowserHtml(input: EmitBrowserHtmlInput): string;
43
+ /** Convenience: the emitted HTML filename for a given JS outfile (sibling `index.html`). */
44
+ export declare function htmlOutPathFor(jsOutPath: string): string;
45
+ /** Derive a JS outfile from an outdir + the module entry basename (`<outdir>/<name>.js`). */
46
+ export declare function jsOutPathForOutdir(outdir: string, moduleEntry: string): string;
@@ -0,0 +1,89 @@
1
+ // HTML-entry support for `gjsify build --app browser`.
2
+ //
3
+ // Vite treats `index.html` as the build entry: it finds the
4
+ // `<script type="module" src="...">`, bundles that module, and emits a
5
+ // processed HTML with the script pointing at the built asset. `gjsify build`
6
+ // only ever took a `.ts`/`.js` entry + `--outfile`, so browser apps hand-wrote
7
+ // their shell page (the `write-app-html.mjs` pattern). This util brings the
8
+ // Vite behaviour to the production Rolldown build.
9
+ //
10
+ // HTML emission MUST run as a CLI post-bundle step (mirroring `applyShebang`),
11
+ // NOT as a Rolldown plugin `emitFile`: the native `@gjsify/rolldown-native`
12
+ // engine implements no `emitFile`, so a plugin-based emit would work on the
13
+ // npm engine (Node) but throw under the GJS-bundled CLI. Parsing is regex-based
14
+ // and tolerant on purpose — this replaces a ~25-line hand-written script, not a
15
+ // full HTML pipeline; CSS is NOT extracted here (it travels in the JS bundle as
16
+ // a string via css-as-string and is injected by the app — the unified pattern
17
+ // shared with the `--app gjs` target).
18
+ import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
19
+ /**
20
+ * Return the `.html` path when `input` is a SINGLE html entry (a literal
21
+ * `.html` string, a 1-element array of one, or a 1-key record whose value is
22
+ * one) — else `null`. Multi-entry inputs and non-html entries (incl. globs
23
+ * like `src/**\/*.ts`) are never treated as html. The path is returned as
24
+ * authored (relative or absolute); the caller resolves + reads it.
25
+ */
26
+ export function detectHtmlEntry(input) {
27
+ if (!input)
28
+ return null;
29
+ const isHtml = (v) => typeof v === 'string' && /\.html?$/i.test(v);
30
+ if (typeof input === 'string')
31
+ return isHtml(input) ? input : null;
32
+ if (Array.isArray(input)) {
33
+ return input.length === 1 && isHtml(input[0]) ? input[0] : null;
34
+ }
35
+ // Record<name, path> form.
36
+ if (typeof input === 'object') {
37
+ const values = Object.values(input);
38
+ return values.length === 1 && isHtml(values[0]) ? values[0] : null;
39
+ }
40
+ return null;
41
+ }
42
+ const SCRIPT_MODULE_RE = /<script\b[^>]*\btype\s*=\s*["']module["'][^>]*>(?:<\/script>)?/gi;
43
+ const SRC_ATTR_RE = /\bsrc\s*=\s*["']([^"']+)["']/i;
44
+ const EXTERNAL_SRC_RE = /^(?:https?:)?\/\//i;
45
+ /**
46
+ * Find the FIRST `<script type="module" src="…">` whose `src` is a local
47
+ * (non-`http(s)://`, non-`//`) path and resolve the referenced module against
48
+ * the HTML file's directory. Throws when no such tag exists.
49
+ */
50
+ export function parseHtmlEntry(htmlPath, htmlSource) {
51
+ for (const match of htmlSource.matchAll(SCRIPT_MODULE_RE)) {
52
+ const scriptTag = match[0];
53
+ const srcMatch = SRC_ATTR_RE.exec(scriptTag);
54
+ if (!srcMatch)
55
+ continue; // inline module script — not an entry
56
+ const scriptSrc = srcMatch[1];
57
+ if (EXTERNAL_SRC_RE.test(scriptSrc))
58
+ continue; // externally hosted — leave alone
59
+ const moduleEntry = isAbsolute(scriptSrc) ? scriptSrc : resolve(dirname(htmlPath), scriptSrc);
60
+ return { moduleEntry, scriptTag, scriptSrc };
61
+ }
62
+ throw new Error(`gjsify build: ${htmlPath} has no local <script type="module" src="…"> to bundle. ` +
63
+ `Add one pointing at your app entry (e.g. <script type="module" src="./main.ts">).`);
64
+ }
65
+ /**
66
+ * Produce the emitted HTML: the original source with the entry script's `src`
67
+ * rewritten to point at the built JS bundle, relative to the output HTML's own
68
+ * directory (so the page is portable regardless of where `dist/` is served
69
+ * from). Everything else — `<title>`, meta tags, other markup — is preserved.
70
+ */
71
+ export function emitBrowserHtml(input) {
72
+ const { htmlSource, scriptTag, scriptSrc, jsOutPath, outHtmlPath } = input;
73
+ let rel = relative(dirname(outHtmlPath), jsOutPath).split(/[\\/]/).join('/');
74
+ if (!rel.startsWith('.'))
75
+ rel = `./${rel}`;
76
+ // Rewrite only the src value WITHIN the matched tag, then swap that tag
77
+ // back into the source — avoids touching any other `src=` in the document.
78
+ const newTag = scriptTag.replace(SRC_ATTR_RE, (whole) => whole.replace(scriptSrc, rel));
79
+ return htmlSource.replace(scriptTag, newTag);
80
+ }
81
+ /** Convenience: the emitted HTML filename for a given JS outfile (sibling `index.html`). */
82
+ export function htmlOutPathFor(jsOutPath) {
83
+ return resolve(dirname(jsOutPath), 'index.html');
84
+ }
85
+ /** Derive a JS outfile from an outdir + the module entry basename (`<outdir>/<name>.js`). */
86
+ export function jsOutPathForOutdir(outdir, moduleEntry) {
87
+ const name = basename(moduleEntry).replace(/\.[cm]?[jt]sx?$/i, '');
88
+ return resolve(outdir, `${name}.js`);
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.16.0",
3
+ "version": "0.16.2",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -122,33 +122,33 @@
122
122
  "cli"
123
123
  ],
124
124
  "dependencies": {
125
- "@gjsify/buffer": "^0.16.0",
126
- "@gjsify/create-app": "^0.16.0",
127
- "@gjsify/node-globals": "^0.16.0",
128
- "@gjsify/node-polyfills": "^0.16.0",
129
- "@gjsify/npm-registry": "^0.16.0",
130
- "@gjsify/resolve-npm": "^0.16.0",
131
- "@gjsify/rolldown-plugin-gjsify": "^0.16.0",
132
- "@gjsify/rolldown-plugin-pnp": "^0.16.0",
133
- "@gjsify/semver": "^0.16.0",
134
- "@gjsify/tar": "^0.16.0",
135
- "@gjsify/tsc": "^0.16.0",
136
- "@gjsify/web-polyfills": "^0.16.0",
137
- "@gjsify/workspace": "^0.16.0",
125
+ "@gjsify/buffer": "^0.16.2",
126
+ "@gjsify/create-app": "^0.16.2",
127
+ "@gjsify/node-globals": "^0.16.2",
128
+ "@gjsify/node-polyfills": "^0.16.2",
129
+ "@gjsify/npm-registry": "^0.16.2",
130
+ "@gjsify/resolve-npm": "^0.16.2",
131
+ "@gjsify/rolldown-plugin-gjsify": "^0.16.2",
132
+ "@gjsify/rolldown-plugin-pnp": "^0.16.2",
133
+ "@gjsify/semver": "^0.16.2",
134
+ "@gjsify/tar": "^0.16.2",
135
+ "@gjsify/tsc": "^0.16.2",
136
+ "@gjsify/web-polyfills": "^0.16.2",
137
+ "@gjsify/workspace": "^0.16.2",
138
138
  "cosmiconfig": "^9.0.2",
139
139
  "get-tsconfig": "^4.14.0",
140
140
  "pkg-types": "^2.3.1",
141
- "rolldown": "^1.1.0",
141
+ "rolldown": "^1.1.4",
142
142
  "yargs": "^18.0.0"
143
143
  },
144
144
  "devDependencies": {
145
- "@gjsify/unit": "^0.16.0",
145
+ "@gjsify/unit": "^0.16.2",
146
146
  "@types/yargs": "^17.0.35",
147
147
  "typescript": "^6.0.3"
148
148
  },
149
149
  "peerDependencies": {
150
- "@gjsify/oxfmt-native": "^0.16.0",
151
- "@gjsify/rolldown-native": "^0.16.0"
150
+ "@gjsify/oxfmt-native": "^0.16.2",
151
+ "@gjsify/rolldown-native": "^0.16.2"
152
152
  },
153
153
  "peerDependenciesMeta": {
154
154
  "@gjsify/oxfmt-native": {