orca-gallery-panel 0.0.0-stage → 1.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jaime Errázuriz Werth
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,205 @@
1
- # Temporary Holding Version
1
+ # Orca Gallery Panel
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/jerrazuw/orca-gallery-panel/actions/workflows/ci.yml/badge.svg)](https://github.com/jerrazuw/orca-gallery-panel/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ **Background images for [Orca](https://orca.dev), the agent IDE.** Orca has no setting for a
7
+ wallpaper or background image, so this plugin adds a side panel that shows a rotating gallery of
8
+ your own pictures while your agents work: wallpapers, photos, art, mood boards.
9
+
10
+ You point it at a folder, it packs the images into an Orca panel plugin, and the panel shuffles
11
+ through them with a slideshow, a blurred background, an optional Ken Burns zoom and controls you
12
+ can change without leaving Orca.
13
+
14
+ ![The gallery panel in Orca's dark and light themes, with the display settings menu open](docs/screenshot.png)
15
+
16
+ ## Features
17
+
18
+ - **Slideshow**: shuffles your images and changes them on an interval you choose. Every image
19
+ shows once before any repeats, and the same one never appears twice in a row.
20
+ - **Controls** on hover: previous, pause/resume, next, a position counter, interval, display
21
+ settings and fit/fill.
22
+ - **Settings in the panel**: click the clock to type the seconds between images (`0` stops the
23
+ automatic change), or the sliders to turn on a blurred background, a slow Ken Burns zoom or
24
+ the image's name. Changes last until Orca reloads the panel, because Orca gives panels no
25
+ storage; the defaults come from your build settings.
26
+ - **Blurred background**: an image that doesn't fill the panel sits on a blurred copy of itself
27
+ instead of empty bars.
28
+ - **Several galleries**, each in its own panel, from one config file.
29
+ - **Watch mode**: `--watch` rebuilds whenever you add or remove images, and Orca reloads the
30
+ panel, so a new photo shows up without touching the terminal.
31
+ - **Keyboard**: `←` / `→` to navigate, `Space` to pause, `F` to switch between fit and fill.
32
+ - **Matches Orca's theme**, light or dark, using the design tokens Orca gives every panel.
33
+ - **Any common format**: PNG, JPEG, GIF, WebP, AVIF and TIFF. Animated GIF and WebP stay
34
+ animated, and EXIF rotation is applied.
35
+ - **English and Spanish**, picked from the system language.
36
+ - **Accessible**: labeled buttons, visible focus, and no fade if you have reduced motion turned on.
37
+ - **Private**: the plugin asks Orca for no permissions and makes no network requests.
38
+
39
+ ## Quick start
40
+
41
+ Requires [Node.js](https://nodejs.org) 22.18 or later and Orca 1.4.160 or later.
42
+
43
+ ```bash
44
+ mkdir ~/orca-gallery && cd ~/orca-gallery
45
+ npx orca-gallery-panel ~/Pictures/wallpapers --title "Wallpapers"
46
+ ```
47
+
48
+ The plugin is written to `dist/` in the current folder. In Orca, go to
49
+ **Settings → Plugins → Development**, click **Add path** and enter the full path to `dist/`
50
+ (here `~/orca-gallery/dist`). Then open the panel from the right sidebar.
51
+
52
+ The examples below use `npm run build`, which is how the command runs from a clone of this
53
+ repository. With `npx`, replace `npm run build --` with `npx orca-gallery-panel` and run it from
54
+ the same folder each time, so it finds your `gallery.config.json` and rebuilds the same `dist/`.
55
+
56
+ To work from a clone instead (also the way to try the bundled samples with
57
+ `npm run build -- examples/images`):
58
+
59
+ ```bash
60
+ git clone https://github.com/jerrazuw/orca-gallery-panel.git
61
+ cd orca-gallery-panel
62
+ npm install
63
+ npm run build -- ~/Pictures/wallpapers --title "Wallpapers"
64
+ ```
65
+
66
+ ### Saving your settings
67
+
68
+ Instead of repeating options on every build, copy the example config and edit it:
69
+
70
+ ```bash
71
+ cp gallery.config.example.json gallery.config.json
72
+ ```
73
+
74
+ ```json
75
+ {
76
+ "$schema": "./gallery.config.schema.json",
77
+ "folder": "~/Pictures/wallpapers",
78
+ "title": "Wallpapers",
79
+ "interval": 60
80
+ }
81
+ ```
82
+
83
+ From then on, `npm run build` is enough. `gallery.config.json` is ignored by git, so your paths
84
+ stay on your machine. The `$schema` line gives you autocomplete and validation in editors that
85
+ support JSON Schema. Options passed on the command line still override the file for that build,
86
+ and `--config <file>` reads a different one.
87
+
88
+ ### Updating your images
89
+
90
+ Add or remove files in your folder and run `npm run build` again. A plugin loaded from
91
+ **Development** reloads on its own.
92
+
93
+ Or leave `npm run build -- --watch` running: it rebuilds whenever an image in your folders or
94
+ the config file changes, and a burst of changes (copying 20 photos at once) triggers a single
95
+ rebuild.
96
+
97
+ If you installed it with **Install plugin → Local folder** instead, Orca keeps a copy, so you
98
+ need to install it again after each build.
99
+
100
+ ## Options
101
+
102
+ | Option | Config key | Default | Description |
103
+ | ----------------------- | ----------- | --------- | ------------------------------------------------- |
104
+ | `[folder]` | `folder` | `images` | Folder with your images. |
105
+ | `-o, --out <dir>` | `out` | `dist` | Output folder for the plugin. |
106
+ | `-t, --title <text>` | `title` | `Gallery` | Panel title shown in Orca. |
107
+ | `-i, --interval <sec>` | `interval` | `180` | Seconds between images. `0` disables rotation. |
108
+ | `-s, --max-size <px>` | `maxSize` | `800` | Longest side of each image, in pixels. |
109
+ | `-q, --quality <1-100>` | `quality` | `80` | WebP quality. |
110
+ | `--fit <mode>` | `fit` | `contain` | Initial mode: `contain` (whole image) or `cover`. |
111
+ | `--effect <name>` | `effect` | `none` | `none`, or `kenburns` for a slow zoom. |
112
+ | `--no-blur` | `blur` | `true` | Blurred background behind images. |
113
+ | `--caption` | `caption` | `false` | Show the image's file name. |
114
+ | `-r, --recursive` | `recursive` | `false` | Include images in subfolders. |
115
+ | | `galleries` | | Several galleries; see above. |
116
+ | `-w, --watch` | | | Rebuild when images or the config file change. |
117
+ | `-c, --config <file>` | | | Config file to read instead of the default. |
118
+
119
+ Precedence: command line, then the gallery's own settings, then the top level of
120
+ `gallery.config.json`, then the defaults. Paths in the config file are relative to the file, and
121
+ `~` means your home folder. The `fit`, `interval`, `blur`, `effect` and `caption` settings are
122
+ only where the panel starts: you can change them from the panel itself.
123
+
124
+ ### Several galleries
125
+
126
+ List them under `galleries`. Each one becomes its own panel in Orca. Settings at the top level
127
+ apply to every gallery unless it sets its own; the top-level `title` names the plugin, and a
128
+ gallery without a `title` is named after its folder.
129
+
130
+ ```json
131
+ {
132
+ "$schema": "./gallery.config.schema.json",
133
+ "title": "My galleries",
134
+ "interval": 120,
135
+ "galleries": [
136
+ { "folder": "~/Pictures/wallpapers", "title": "Wallpapers", "effect": "kenburns" },
137
+ { "folder": "~/Pictures/art", "interval": 30, "caption": true }
138
+ ]
139
+ }
140
+ ```
141
+
142
+ Each panel has to stay under Orca's 10 MiB limit for a panel, and all of them together under
143
+ its 50 MiB limit for a plugin.
144
+
145
+ ## How it works
146
+
147
+ Orca renders a plugin panel as a sandboxed `srcdoc` iframe whose Content Security Policy only
148
+ allows inline scripts and styles and `data:` images. A panel cannot read files from disk, and
149
+ Orca's plugin API gives it no file or folder access either.
150
+
151
+ So the images are packed into the panel when you build it:
152
+
153
+ 1. `src/images.ts` finds the supported images in your folder, in natural order.
154
+ 2. `src/encode.ts` uses [sharp](https://sharp.pixelplumbing.com) to apply EXIF rotation, shrink
155
+ each image to `--max-size` and re-encode it as WebP. That keeps transparency and animation,
156
+ and is far smaller than base64-encoded PNGs or GIFs.
157
+ 3. `src/html.ts` bundles the panel script (`src/panel/`, TypeScript) with
158
+ [esbuild](https://esbuild.github.io) and inlines it, the CSS and the images into one
159
+ `panel.html`.
160
+ 4. `src/build.ts` checks the result against Orca's 10 MiB limit for a panel. If it's over, it
161
+ names the largest images and suggests what to change.
162
+ 5. `src/manifest.ts` writes `orca-plugin.json`, taking the version from `package.json`, with
163
+ one panel per gallery.
164
+
165
+ At the default settings a typical photo ends up around 40 KB, so the limit fits a couple of
166
+ hundred images. Animated GIFs are much heavier.
167
+
168
+ ## Development
169
+
170
+ ```bash
171
+ npm run check # typecheck, lint, format check and unit tests
172
+ npm run test:e2e # the panel in headless Chromium (first run: npx playwright install chromium)
173
+ npm run preview # writes dist/preview.html: the built panel inside an Orca-like frame
174
+ ```
175
+
176
+ | Path | What it is |
177
+ | ------------------------------ | ----------------------------------------------------------------------------- |
178
+ | `src/cli.ts`, `src/options.ts` | Command-line entry point and argument parsing. |
179
+ | `src/build.ts` | Builds the plugin: find, encode, render, check size, write. |
180
+ | `src/panel/` | The code that runs inside Orca: slideshow, controls, translations and styles. |
181
+ | `src/watch.ts` | Watch mode: reruns the build when the watched folders change. |
182
+ | `src/preview.ts` | Reproduces Orca's panel shell so the panel can be checked in a browser. |
183
+ | `test/` | Unit tests (`node:test`) and browser tests (`test/e2e/`, Playwright). |
184
+ | `examples/images/` | Sample images, made by `scripts/generate-examples.ts`. |
185
+
186
+ TypeScript runs directly on Node through
187
+ [type stripping](https://nodejs.org/api/typescript.html#type-stripping), so there is no compile
188
+ step while you work on it. Node won't strip types inside `node_modules`, so the npm package is
189
+ compiled to `lib/` by `npm run build:lib`, which `npm pack` runs on its own. CI runs on Linux,
190
+ macOS and Windows with Node 22.18, 22 and 24, and installs the packed package in an empty
191
+ project to check that the command works.
192
+
193
+ ## Why it isn't in the Orca marketplace
194
+
195
+ Plugins in the [Orca marketplace](https://github.com/stablyai/orca-plugins) are installed from a
196
+ git repository as they are, but this panel only works once it has been built with your images. A marketplace entry could only ship the sample gallery.
197
+
198
+ ## Contributing
199
+
200
+ Issues and pull requests are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md).
201
+
202
+ ## License
203
+
204
+ [MIT](LICENSE). The sample images in `examples/images/` are generated by code in this repository
205
+ and are covered by the same license.
@@ -0,0 +1,6 @@
1
+ {
2
+ "$schema": "./gallery.config.schema.json",
3
+ "folder": "~/Pictures/wallpapers",
4
+ "title": "Wallpapers",
5
+ "interval": 60
6
+ }
@@ -0,0 +1,127 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "Orca Gallery Panel settings",
4
+ "description": "Saved options for the gallery build. Command-line options override these.",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "$schema": {
9
+ "type": "string"
10
+ },
11
+ "folder": {
12
+ "type": "string",
13
+ "minLength": 1,
14
+ "description": "Folder with your images. Relative paths start from this file; ~ is your home folder.",
15
+ "default": "images"
16
+ },
17
+ "title": {
18
+ "type": "string",
19
+ "minLength": 1,
20
+ "maxLength": 256,
21
+ "description": "Panel title shown in Orca. With \"galleries\", it names the plugin instead.",
22
+ "default": "Gallery"
23
+ },
24
+ "interval": {
25
+ "type": "integer",
26
+ "minimum": 0,
27
+ "maximum": 86400,
28
+ "description": "Seconds between images. 0 disables automatic rotation.",
29
+ "default": 180
30
+ },
31
+ "maxSize": {
32
+ "type": "integer",
33
+ "minimum": 16,
34
+ "maximum": 8192,
35
+ "description": "Longest side of each image, in pixels.",
36
+ "default": 800
37
+ },
38
+ "quality": {
39
+ "type": "integer",
40
+ "minimum": 1,
41
+ "maximum": 100,
42
+ "description": "WebP quality.",
43
+ "default": 80
44
+ },
45
+ "fit": {
46
+ "enum": ["contain", "cover"],
47
+ "description": "Initial mode: contain shows the whole image, cover fills the panel.",
48
+ "default": "contain"
49
+ },
50
+ "blur": {
51
+ "type": "boolean",
52
+ "description": "Show a blurred copy of the image behind it when it doesn't fill the panel.",
53
+ "default": true
54
+ },
55
+ "effect": {
56
+ "enum": ["none", "kenburns"],
57
+ "description": "none, or kenburns for a slow zoom while each image is shown.",
58
+ "default": "none"
59
+ },
60
+ "caption": {
61
+ "type": "boolean",
62
+ "description": "Show the image's file name at the top of the panel.",
63
+ "default": false
64
+ },
65
+ "recursive": {
66
+ "type": "boolean",
67
+ "description": "Include images in subfolders.",
68
+ "default": false
69
+ },
70
+ "out": {
71
+ "type": "string",
72
+ "minLength": 1,
73
+ "description": "Output folder for the plugin.",
74
+ "default": "dist"
75
+ },
76
+ "galleries": {
77
+ "type": "array",
78
+ "minItems": 1,
79
+ "description": "Several galleries, each in its own panel. Settings at the top level apply to all of them unless a gallery sets its own.",
80
+ "items": {
81
+ "$ref": "#/$defs/gallery"
82
+ }
83
+ }
84
+ },
85
+ "$defs": {
86
+ "gallery": {
87
+ "type": "object",
88
+ "additionalProperties": false,
89
+ "required": ["folder"],
90
+ "properties": {
91
+ "folder": {
92
+ "$ref": "#/properties/folder"
93
+ },
94
+ "title": {
95
+ "type": "string",
96
+ "minLength": 1,
97
+ "maxLength": 256,
98
+ "description": "Panel title. Defaults to the folder's name."
99
+ },
100
+ "interval": {
101
+ "$ref": "#/properties/interval"
102
+ },
103
+ "maxSize": {
104
+ "$ref": "#/properties/maxSize"
105
+ },
106
+ "quality": {
107
+ "$ref": "#/properties/quality"
108
+ },
109
+ "fit": {
110
+ "$ref": "#/properties/fit"
111
+ },
112
+ "blur": {
113
+ "$ref": "#/properties/blur"
114
+ },
115
+ "effect": {
116
+ "$ref": "#/properties/effect"
117
+ },
118
+ "caption": {
119
+ "$ref": "#/properties/caption"
120
+ },
121
+ "recursive": {
122
+ "$ref": "#/properties/recursive"
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
package/lib/build.js ADDED
@@ -0,0 +1,129 @@
1
+ import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { encodeImage } from "./encode.js";
4
+ import { renderPanelHtml } from "./html.js";
5
+ import { findImages, labelFor } from "./images.js";
6
+ import { createManifest, isGeneratedPanelFile, MANIFEST_FILE, panelEntries } from "./manifest.js";
7
+ /** Orca rejects a panel entry above this size (PLUGIN_PANEL_ENTRY_MAX_BYTES). */
8
+ export const PANEL_ENTRY_MAX_BYTES = 10 * 1024 * 1024;
9
+ /** Orca rejects a whole plugin above this size. */
10
+ export const PLUGIN_MAX_BYTES = 50 * 1024 * 1024;
11
+ const ENCODE_CONCURRENCY = 4;
12
+ export class BuildError extends Error {
13
+ name = 'BuildError';
14
+ }
15
+ async function mapConcurrent(items, limit, task) {
16
+ const results = new Array(items.length);
17
+ let cursor = 0;
18
+ const worker = async () => {
19
+ while (cursor < items.length) {
20
+ const index = cursor++;
21
+ results[index] = await task(items[index]);
22
+ }
23
+ };
24
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
25
+ return results;
26
+ }
27
+ export function formatBytes(bytes) {
28
+ if (bytes < 1024)
29
+ return `${String(bytes)} B`;
30
+ if (bytes < 1024 * 1024)
31
+ return `${(bytes / 1024).toFixed(0)} KB`;
32
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
33
+ }
34
+ export function oversizeMessage(title, panelBytes, images) {
35
+ const largest = [...images]
36
+ .sort((a, b) => b.bytes - a.bytes)
37
+ .slice(0, 5)
38
+ .map((image) => ` ${formatBytes(image.bytes).padStart(8)} ${basename(image.file)}${image.animated ? ' (animated)' : ''}`);
39
+ return [
40
+ `"${title}" is ${formatBytes(panelBytes)}, over Orca's ${formatBytes(PANEL_ENTRY_MAX_BYTES)} limit for a panel.`,
41
+ 'Largest images:',
42
+ ...largest,
43
+ 'Try a smaller --max-size or --quality, or remove some images.',
44
+ ].join('\n');
45
+ }
46
+ async function assertFolder(folder) {
47
+ const info = await stat(folder).catch(() => undefined);
48
+ if (!info?.isDirectory())
49
+ throw new BuildError(`Image folder not found: ${folder}`);
50
+ }
51
+ async function buildGallery(gallery, logger) {
52
+ await assertFolder(gallery.input);
53
+ const files = await findImages(gallery.input, gallery.recursive);
54
+ const skipped = [];
55
+ const encoded = await mapConcurrent(files, ENCODE_CONCURRENCY, async (file) => {
56
+ try {
57
+ return { file, image: await encodeImage(file, gallery) };
58
+ }
59
+ catch (error) {
60
+ const reason = error instanceof Error ? error.message : String(error);
61
+ skipped.push({ file, reason });
62
+ logger.warn(`Skipping ${basename(file)}: ${reason}`);
63
+ return undefined;
64
+ }
65
+ });
66
+ const ok = encoded.filter((entry) => entry !== undefined);
67
+ if (ok.length === 0) {
68
+ throw new BuildError(files.length === 0
69
+ ? `No supported images in ${gallery.input}${gallery.recursive ? '' : ' (use --recursive to include subfolders)'}`
70
+ : `None of the ${String(files.length)} images in ${gallery.input} could be read`);
71
+ }
72
+ const images = ok.map(({ file, image }) => ({
73
+ src: image.dataUrl,
74
+ label: labelFor(file),
75
+ }));
76
+ const reports = ok.map(({ file, image }) => ({
77
+ file,
78
+ bytes: image.bytes,
79
+ width: image.width,
80
+ height: image.height,
81
+ animated: image.animated,
82
+ }));
83
+ const html = await renderPanelHtml({
84
+ images,
85
+ intervalMs: gallery.intervalSeconds * 1000,
86
+ fit: gallery.fit,
87
+ blur: gallery.blur,
88
+ effect: gallery.effect,
89
+ caption: gallery.caption,
90
+ });
91
+ const panelBytes = Buffer.byteLength(html);
92
+ if (panelBytes > PANEL_ENTRY_MAX_BYTES) {
93
+ throw new BuildError(oversizeMessage(gallery.title, panelBytes, reports));
94
+ }
95
+ return { html, images: reports, skipped };
96
+ }
97
+ /** Removes panel files an earlier build wrote for galleries that no longer exist. */
98
+ async function removeStalePanels(outDir, keep) {
99
+ const files = await readdir(outDir);
100
+ await Promise.all(files
101
+ .filter((file) => isGeneratedPanelFile(file) && !keep.has(file))
102
+ .map((file) => rm(join(outDir, file))));
103
+ }
104
+ export async function buildPlugin(options, logger = console) {
105
+ const entries = panelEntries(options.galleries.map((gallery) => gallery.title));
106
+ const built = [];
107
+ for (const gallery of options.galleries)
108
+ built.push(await buildGallery(gallery, logger));
109
+ const totalBytes = built.reduce((sum, { html }) => sum + Buffer.byteLength(html), 0);
110
+ if (totalBytes > PLUGIN_MAX_BYTES) {
111
+ throw new BuildError(`The galleries add up to ${formatBytes(totalBytes)}, over Orca's ${formatBytes(PLUGIN_MAX_BYTES)} limit for a plugin.`);
112
+ }
113
+ await mkdir(options.outDir, { recursive: true });
114
+ await Promise.all([
115
+ ...built.map(({ html }, i) => writeFile(join(options.outDir, entries[i]?.entry ?? ''), html)),
116
+ writeFile(join(options.outDir, MANIFEST_FILE), `${JSON.stringify(createManifest(options.name, entries), null, 2)}\n`),
117
+ ]);
118
+ await removeStalePanels(options.outDir, new Set(entries.map(({ entry }) => entry)));
119
+ return {
120
+ outDir: options.outDir,
121
+ galleries: built.map(({ html, images, skipped }, i) => ({
122
+ title: entries[i]?.title ?? '',
123
+ entry: entries[i]?.entry ?? '',
124
+ panelBytes: Buffer.byteLength(html),
125
+ images,
126
+ skipped,
127
+ })),
128
+ };
129
+ }
package/lib/cli.js ADDED
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ import { basename, dirname, relative, resolve, sep } from 'node:path';
3
+ import { pkg } from "./package-info.js";
4
+ import { buildPlugin, BuildError, formatBytes, PANEL_ENTRY_MAX_BYTES, } from "./build.js";
5
+ import { loadConfigFile } from "./config-file.js";
6
+ import { isSupportedImage } from "./images.js";
7
+ import { CONFIG_FILE, HELP, parseCommand, resolveOptions, UsageError, } from "./options.js";
8
+ import { watchLoop } from "./watch.js";
9
+ function configFilePath({ configPath, cwd }) {
10
+ return resolve(cwd, configPath ?? CONFIG_FILE);
11
+ }
12
+ async function loadOptions(invocation) {
13
+ const { settings, configPath, cwd } = invocation;
14
+ const configFile = await loadConfigFile(configFilePath(invocation), {
15
+ required: configPath !== undefined,
16
+ });
17
+ if (configFile)
18
+ console.log(`Using settings from ${configPath ?? CONFIG_FILE}`);
19
+ return resolveOptions([...(configFile ? [configFile] : []), { settings, baseDir: cwd }], cwd);
20
+ }
21
+ function displayPath(path, cwd) {
22
+ const relativePath = relative(cwd, path);
23
+ if (relativePath === '')
24
+ return '.';
25
+ return relativePath.startsWith('..') ? path : relativePath;
26
+ }
27
+ function plural(count, noun) {
28
+ return `${String(count)} ${noun}${count === 1 ? '' : 's'}`;
29
+ }
30
+ function report(options, result, cwd) {
31
+ const where = displayPath(result.outDir, cwd);
32
+ const usage = (bytes) => `${((bytes / PANEL_ENTRY_MAX_BYTES) * 100).toFixed(0)}%`;
33
+ const [only] = result.galleries;
34
+ if (result.galleries.length === 1 && only) {
35
+ console.log(`Built "${only.title}" with ${plural(only.images.length, 'image')}: ` +
36
+ `${formatBytes(only.panelBytes)} (${usage(only.panelBytes)} of Orca's limit) in ${where}`);
37
+ }
38
+ else {
39
+ console.log(`Built "${options.name}" with ${String(result.galleries.length)} panels in ${where}:`);
40
+ const width = Math.max(...result.galleries.map((gallery) => gallery.title.length));
41
+ for (const gallery of result.galleries) {
42
+ console.log(` ${gallery.title.padEnd(width)} ${plural(gallery.images.length, 'image').padStart(10)} ` +
43
+ `${formatBytes(gallery.panelBytes).padStart(8)} (${usage(gallery.panelBytes)} of the limit)`);
44
+ }
45
+ }
46
+ const skipped = result.galleries.reduce((sum, gallery) => sum + gallery.skipped.length, 0);
47
+ if (skipped > 0)
48
+ console.warn(`${String(skipped)} file(s) skipped.`);
49
+ }
50
+ function watchTargets(invocation, options) {
51
+ const config = configFilePath(invocation);
52
+ const targets = [
53
+ {
54
+ path: dirname(config),
55
+ recursive: false,
56
+ accept: (file) => basename(file) === basename(config),
57
+ },
58
+ ];
59
+ for (const gallery of options?.galleries ?? []) {
60
+ targets.push({
61
+ path: gallery.input,
62
+ recursive: true,
63
+ accept: (file) => isSupportedImage(file) && (gallery.recursive || !file.includes(sep)),
64
+ });
65
+ }
66
+ return targets;
67
+ }
68
+ function describe(error) {
69
+ return error instanceof Error ? error.message : String(error);
70
+ }
71
+ async function watchAndBuild(invocation) {
72
+ const controller = new AbortController();
73
+ process.once('SIGINT', () => {
74
+ controller.abort();
75
+ });
76
+ await watchLoop(async () => {
77
+ let options;
78
+ try {
79
+ options = await loadOptions(invocation);
80
+ report(options, await buildPlugin(options), invocation.cwd);
81
+ }
82
+ catch (error) {
83
+ if (!(error instanceof UsageError || error instanceof BuildError))
84
+ throw error;
85
+ console.error(describe(error));
86
+ }
87
+ console.log(`[${new Date().toLocaleTimeString()}] Watching for changes. Ctrl+C to stop.`);
88
+ return watchTargets(invocation, options);
89
+ }, {
90
+ signal: controller.signal,
91
+ onSkip: (target, error) => {
92
+ console.warn(`Not watching ${target.path}: ${describe(error)}`);
93
+ },
94
+ });
95
+ }
96
+ async function main(argv) {
97
+ const command = parseCommand(argv);
98
+ if (command.kind === 'help') {
99
+ process.stdout.write(HELP);
100
+ return 0;
101
+ }
102
+ if (command.kind === 'version') {
103
+ process.stdout.write(`${pkg.version}\n`);
104
+ return 0;
105
+ }
106
+ const invocation = {
107
+ settings: command.settings,
108
+ configPath: command.configPath,
109
+ cwd: process.cwd(),
110
+ };
111
+ if (command.watch) {
112
+ await watchAndBuild(invocation);
113
+ return 0;
114
+ }
115
+ const options = await loadOptions(invocation);
116
+ report(options, await buildPlugin(options), invocation.cwd);
117
+ console.log('Load it in Orca from Settings > Plugins > Development > Add path; it reloads on every build.\n' +
118
+ 'Add --watch to rebuild on its own whenever your images change.');
119
+ return 0;
120
+ }
121
+ main(process.argv.slice(2)).then((code) => {
122
+ process.exitCode = code;
123
+ }, (error) => {
124
+ if (error instanceof UsageError) {
125
+ console.error(`${error.message}\n\nRun with --help for usage.`);
126
+ process.exitCode = 2;
127
+ }
128
+ else if (error instanceof BuildError) {
129
+ console.error(error.message);
130
+ process.exitCode = 1;
131
+ }
132
+ else {
133
+ console.error(error);
134
+ process.exitCode = 1;
135
+ }
136
+ });
@@ -0,0 +1,38 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename, dirname, resolve } from 'node:path';
3
+ import { SETTING_KEYS, UsageError, validateSettings } from "./options.js";
4
+ /**
5
+ * Reads saved build settings so a rebuild is just `npm run build`. Returns
6
+ * undefined when an optional file is missing; a file that exists but is
7
+ * invalid always fails, so a typo never silently falls back to defaults.
8
+ */
9
+ export async function loadConfigFile(path, { required }) {
10
+ const file = resolve(path);
11
+ let text;
12
+ try {
13
+ text = await readFile(file, 'utf8');
14
+ }
15
+ catch (error) {
16
+ if (!required && error.code === 'ENOENT')
17
+ return undefined;
18
+ throw new UsageError(`Cannot read config file ${file}`);
19
+ }
20
+ let raw;
21
+ try {
22
+ raw = JSON.parse(text);
23
+ }
24
+ catch (error) {
25
+ throw new UsageError(`${basename(file)} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
26
+ }
27
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
28
+ throw new UsageError(`${basename(file)} must contain a JSON object`);
29
+ }
30
+ const entries = raw;
31
+ const unknown = Object.keys(entries).filter((key) => key !== '$schema' && !SETTING_KEYS.includes(key));
32
+ if (unknown.length > 0) {
33
+ throw new UsageError(`Unknown setting ${unknown.map((key) => `"${key}"`).join(', ')} in ${basename(file)}. ` +
34
+ `Valid settings: ${SETTING_KEYS.join(', ')}`);
35
+ }
36
+ const settings = validateSettings(entries, (key) => `"${key}" in ${basename(file)}`);
37
+ return { settings, baseDir: dirname(file) };
38
+ }