domotion-svg 0.1.1
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/FEATURES.md +102 -0
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/animator.d.ts +158 -0
- package/dist/animator.js +424 -0
- package/dist/animator.test.d.ts +5 -0
- package/dist/animator.test.js +169 -0
- package/dist/border-radius.test.d.ts +1 -0
- package/dist/border-radius.test.js +148 -0
- package/dist/capture.d.ts +193 -0
- package/dist/capture.js +786 -0
- package/dist/chrome.d.ts +45 -0
- package/dist/chrome.js +107 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +512 -0
- package/dist/client/dom.d.ts +10 -0
- package/dist/client/dom.js +17 -0
- package/dist/conic-raster.d.ts +58 -0
- package/dist/conic-raster.js +292 -0
- package/dist/conic-raster.test.d.ts +1 -0
- package/dist/conic-raster.test.js +187 -0
- package/dist/coretext-extractor.test.d.ts +1 -0
- package/dist/coretext-extractor.test.js +94 -0
- package/dist/coretext-helper.d.ts +60 -0
- package/dist/coretext-helper.js +205 -0
- package/dist/cross-origin-font-face.test.d.ts +1 -0
- package/dist/cross-origin-font-face.test.js +107 -0
- package/dist/cursor-overlay.d.ts +123 -0
- package/dist/cursor-overlay.js +207 -0
- package/dist/cursor-overlay.test.d.ts +1 -0
- package/dist/cursor-overlay.test.js +88 -0
- package/dist/dark-mode-capture.test.d.ts +1 -0
- package/dist/dark-mode-capture.test.js +158 -0
- package/dist/dark-mode-form-controls.test.d.ts +1 -0
- package/dist/dark-mode-form-controls.test.js +218 -0
- package/dist/dom-to-svg.d.ts +1016 -0
- package/dist/dom-to-svg.js +7717 -0
- package/dist/embed-remote-images.test.d.ts +1 -0
- package/dist/embed-remote-images.test.js +424 -0
- package/dist/form-controls.d.ts +70 -0
- package/dist/form-controls.js +1151 -0
- package/dist/frame-merge.d.ts +95 -0
- package/dist/frame-merge.js +374 -0
- package/dist/frame-merge.test.d.ts +6 -0
- package/dist/frame-merge.test.js +144 -0
- package/dist/gradients.d.ts +184 -0
- package/dist/gradients.js +937 -0
- package/dist/gradients.test.d.ts +1 -0
- package/dist/gradients.test.js +150 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +7 -0
- package/dist/jsx-runtime.d.ts +27 -0
- package/dist/jsx-runtime.js +96 -0
- package/dist/jsx-runtime.test.d.ts +1 -0
- package/dist/jsx-runtime.test.js +41 -0
- package/dist/kerfjs-imports.test.d.ts +1 -0
- package/dist/kerfjs-imports.test.js +36 -0
- package/dist/mask.test.d.ts +1 -0
- package/dist/mask.test.js +206 -0
- package/dist/optimize.d.ts +12 -0
- package/dist/optimize.js +32 -0
- package/dist/preserve-aspect-ratio.test.d.ts +1 -0
- package/dist/preserve-aspect-ratio.test.js +38 -0
- package/dist/resize-embedded-images.d.ts +33 -0
- package/dist/resize-embedded-images.js +164 -0
- package/dist/resize-embedded-images.test.d.ts +9 -0
- package/dist/resize-embedded-images.test.js +255 -0
- package/dist/stacking-context.test.d.ts +1 -0
- package/dist/stacking-context.test.js +927 -0
- package/dist/text-renderer.d.ts +42 -0
- package/dist/text-renderer.js +608 -0
- package/dist/text-renderer.test.d.ts +1 -0
- package/dist/text-renderer.test.js +150 -0
- package/dist/text-to-path.d.ts +265 -0
- package/dist/text-to-path.js +1800 -0
- package/dist/text-to-path.test.d.ts +1 -0
- package/dist/text-to-path.test.js +570 -0
- package/dist/utils/escapeHtml.d.ts +2 -0
- package/dist/utils/escapeHtml.js +15 -0
- package/dist/webfont-unicode-range.test.d.ts +1 -0
- package/dist/webfont-unicode-range.test.js +174 -0
- package/package.json +55 -0
- package/src/animator.test.ts +179 -0
- package/src/animator.ts +660 -0
- package/src/border-radius.test.ts +160 -0
- package/src/capture.ts +810 -0
- package/src/cli.ts +582 -0
- package/src/conic-raster.test.ts +213 -0
- package/src/conic-raster.ts +309 -0
- package/src/coretext-extractor.test.ts +130 -0
- package/src/coretext-helper.ts +256 -0
- package/src/cross-origin-font-face.test.ts +119 -0
- package/src/cursor-overlay.test.ts +95 -0
- package/src/cursor-overlay.ts +297 -0
- package/src/dark-mode-capture.test.ts +177 -0
- package/src/dark-mode-form-controls.test.ts +228 -0
- package/src/dom-to-svg.ts +8376 -0
- package/src/embed-remote-images.test.ts +461 -0
- package/src/form-controls.ts +1174 -0
- package/src/frame-merge.test.ts +157 -0
- package/src/frame-merge.ts +447 -0
- package/src/globals.d.ts +2 -0
- package/src/gradients.test.ts +175 -0
- package/src/gradients.ts +955 -0
- package/src/index.ts +12 -0
- package/src/kerf-jsx-augmentation.d.ts +36 -0
- package/src/kerfjs-imports.test.tsx +45 -0
- package/src/mask.test.ts +274 -0
- package/src/optimize.ts +34 -0
- package/src/preserve-aspect-ratio.test.ts +49 -0
- package/src/resize-embedded-images.test.ts +292 -0
- package/src/resize-embedded-images.ts +180 -0
- package/src/stacking-context.test.ts +967 -0
- package/src/text-renderer.test.ts +162 -0
- package/src/text-renderer.ts +623 -0
- package/src/text-to-path.test.ts +639 -0
- package/src/text-to-path.ts +1810 -0
- package/src/utils/escapeHtml.ts +16 -0
- package/src/webfont-unicode-range.test.ts +207 -0
package/dist/chrome.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device chrome wrappers — render terminal / browser / phone bezels
|
|
3
|
+
* around captured SVG content.
|
|
4
|
+
*
|
|
5
|
+
* The chrome adds margin and decorative elements at known fixed offsets,
|
|
6
|
+
* then translates the captured content into the inner area. The total
|
|
7
|
+
* rendered SVG grows by the chrome's outer dimensions.
|
|
8
|
+
*
|
|
9
|
+
* Each renderer returns:
|
|
10
|
+
* - `outerWidth` / `outerHeight` — the resulting SVG's full size.
|
|
11
|
+
* - `contentX` / `contentY` — where the captured content should be drawn.
|
|
12
|
+
* - `defs` — extra `<defs>` content to embed in the wrapper SVG.
|
|
13
|
+
* - `before` / `after` — markup to wrap the translated captured content.
|
|
14
|
+
*
|
|
15
|
+
* The composer (`generateAnimatedSvg` / `wrapWithChrome`) is responsible for
|
|
16
|
+
* stitching these into the final SVG.
|
|
17
|
+
*/
|
|
18
|
+
export type DeviceChromeKind = "terminal" | "browser" | "phone";
|
|
19
|
+
export interface DeviceChromeConfig {
|
|
20
|
+
type: DeviceChromeKind;
|
|
21
|
+
/** For browser chrome: the URL to display in the address bar. */
|
|
22
|
+
url?: string;
|
|
23
|
+
/** For terminal/browser chrome: the title text in the title bar / tab. */
|
|
24
|
+
title?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface ChromeFrame {
|
|
27
|
+
outerWidth: number;
|
|
28
|
+
outerHeight: number;
|
|
29
|
+
contentX: number;
|
|
30
|
+
contentY: number;
|
|
31
|
+
defs: string;
|
|
32
|
+
before: string;
|
|
33
|
+
after: string;
|
|
34
|
+
}
|
|
35
|
+
export declare function buildChrome(config: DeviceChromeConfig, contentWidth: number, contentHeight: number): ChromeFrame;
|
|
36
|
+
/**
|
|
37
|
+
* Wrap a complete `<svg>...</svg>` document in device chrome. The chrome
|
|
38
|
+
* is rendered as outer SVG markup and the captured content is embedded
|
|
39
|
+
* via a translated `<g>`.
|
|
40
|
+
*
|
|
41
|
+
* `inner` should be the SVG fragment (`defs + groups`) returned by
|
|
42
|
+
* `elementTreeToSvg` (NOT a wrapped `<svg>` document) so it can be embedded
|
|
43
|
+
* directly into the chrome's translation group.
|
|
44
|
+
*/
|
|
45
|
+
export declare function wrapWithChrome(inner: string, contentWidth: number, contentHeight: number, chrome: DeviceChromeConfig): string;
|
package/dist/chrome.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device chrome wrappers — render terminal / browser / phone bezels
|
|
3
|
+
* around captured SVG content.
|
|
4
|
+
*
|
|
5
|
+
* The chrome adds margin and decorative elements at known fixed offsets,
|
|
6
|
+
* then translates the captured content into the inner area. The total
|
|
7
|
+
* rendered SVG grows by the chrome's outer dimensions.
|
|
8
|
+
*
|
|
9
|
+
* Each renderer returns:
|
|
10
|
+
* - `outerWidth` / `outerHeight` — the resulting SVG's full size.
|
|
11
|
+
* - `contentX` / `contentY` — where the captured content should be drawn.
|
|
12
|
+
* - `defs` — extra `<defs>` content to embed in the wrapper SVG.
|
|
13
|
+
* - `before` / `after` — markup to wrap the translated captured content.
|
|
14
|
+
*
|
|
15
|
+
* The composer (`generateAnimatedSvg` / `wrapWithChrome`) is responsible for
|
|
16
|
+
* stitching these into the final SVG.
|
|
17
|
+
*/
|
|
18
|
+
export function buildChrome(config, contentWidth, contentHeight) {
|
|
19
|
+
if (config.type === "terminal")
|
|
20
|
+
return terminalChrome(contentWidth, contentHeight, config.title ?? "Terminal");
|
|
21
|
+
if (config.type === "browser")
|
|
22
|
+
return browserChrome(contentWidth, contentHeight, config.url ?? "https://example.com", config.title ?? "");
|
|
23
|
+
if (config.type === "phone")
|
|
24
|
+
return phoneChrome(contentWidth, contentHeight);
|
|
25
|
+
throw new Error(`buildChrome: unknown chrome type "${config.type}"`);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Wrap a complete `<svg>...</svg>` document in device chrome. The chrome
|
|
29
|
+
* is rendered as outer SVG markup and the captured content is embedded
|
|
30
|
+
* via a translated `<g>`.
|
|
31
|
+
*
|
|
32
|
+
* `inner` should be the SVG fragment (`defs + groups`) returned by
|
|
33
|
+
* `elementTreeToSvg` (NOT a wrapped `<svg>` document) so it can be embedded
|
|
34
|
+
* directly into the chrome's translation group.
|
|
35
|
+
*/
|
|
36
|
+
export function wrapWithChrome(inner, contentWidth, contentHeight, chrome) {
|
|
37
|
+
const f = buildChrome(chrome, contentWidth, contentHeight);
|
|
38
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${f.outerWidth} ${f.outerHeight}" width="${f.outerWidth}" height="${f.outerHeight}">`
|
|
39
|
+
+ (f.defs !== "" ? `<defs>${f.defs}</defs>` : "")
|
|
40
|
+
+ f.before
|
|
41
|
+
+ `<g transform="translate(${f.contentX}, ${f.contentY})">${inner}</g>`
|
|
42
|
+
+ f.after
|
|
43
|
+
+ `</svg>`;
|
|
44
|
+
}
|
|
45
|
+
function terminalChrome(width, height, title) {
|
|
46
|
+
const padding = 16;
|
|
47
|
+
const titleBarHeight = 36;
|
|
48
|
+
const outerWidth = width + padding * 2;
|
|
49
|
+
const outerHeight = height + titleBarHeight + padding;
|
|
50
|
+
const contentX = padding;
|
|
51
|
+
const contentY = titleBarHeight;
|
|
52
|
+
const before = `<rect width="${outerWidth}" height="${outerHeight}" rx="10" fill="#1e1e2e" />`
|
|
53
|
+
+ `<rect width="${outerWidth}" height="${titleBarHeight}" rx="10" fill="#2b2b3d" />`
|
|
54
|
+
+ `<rect y="${titleBarHeight - 4}" width="${outerWidth}" height="4" fill="#2b2b3d" />`
|
|
55
|
+
+ `<circle cx="20" cy="${titleBarHeight / 2}" r="6" fill="#ff5f57" />`
|
|
56
|
+
+ `<circle cx="40" cy="${titleBarHeight / 2}" r="6" fill="#febc2e" />`
|
|
57
|
+
+ `<circle cx="60" cy="${titleBarHeight / 2}" r="6" fill="#28c840" />`
|
|
58
|
+
+ `<text x="${outerWidth / 2}" y="${titleBarHeight / 2 + 4}" text-anchor="middle" font-family="-apple-system, sans-serif" font-size="12" fill="#8b8fa3">${escapeXml(title)}</text>`;
|
|
59
|
+
return { outerWidth, outerHeight, contentX, contentY, defs: "", before, after: "" };
|
|
60
|
+
}
|
|
61
|
+
function browserChrome(width, height, url, title) {
|
|
62
|
+
const titleBarHeight = 40;
|
|
63
|
+
const addressBarHeight = 32;
|
|
64
|
+
const chromeHeight = titleBarHeight + addressBarHeight;
|
|
65
|
+
const outerWidth = width;
|
|
66
|
+
const outerHeight = height + chromeHeight;
|
|
67
|
+
const contentX = 0;
|
|
68
|
+
const contentY = chromeHeight;
|
|
69
|
+
const tabLabel = title === "" ? hostFrom(url) : title;
|
|
70
|
+
const before = `<rect width="${outerWidth}" height="${outerHeight}" rx="10" fill="#161b22" />`
|
|
71
|
+
+ `<rect width="${outerWidth}" height="${chromeHeight}" rx="10" fill="#21262d" />`
|
|
72
|
+
+ `<rect y="${chromeHeight - 4}" width="${outerWidth}" height="4" fill="#21262d" />`
|
|
73
|
+
+ `<circle cx="20" cy="20" r="6" fill="#ff5f57" />`
|
|
74
|
+
+ `<circle cx="40" cy="20" r="6" fill="#febc2e" />`
|
|
75
|
+
+ `<circle cx="60" cy="20" r="6" fill="#28c840" />`
|
|
76
|
+
+ `<rect x="80" y="8" width="180" height="24" rx="6" fill="#161b22" />`
|
|
77
|
+
+ `<text x="92" y="24" font-family="-apple-system, sans-serif" font-size="11" fill="#e6edf3">${escapeXml(tabLabel)}</text>`
|
|
78
|
+
+ `<rect x="12" y="${titleBarHeight + 4}" width="${outerWidth - 24}" height="24" rx="6" fill="#0d1117" />`
|
|
79
|
+
+ `<text x="24" y="${titleBarHeight + 20}" font-family="-apple-system, sans-serif" font-size="11" fill="#8b949e">${escapeXml(url)}</text>`;
|
|
80
|
+
return { outerWidth, outerHeight, contentX, contentY, defs: "", before, after: "" };
|
|
81
|
+
}
|
|
82
|
+
function phoneChrome(width, height) {
|
|
83
|
+
const framePadding = 12;
|
|
84
|
+
const statusBarHeight = 44;
|
|
85
|
+
const homeIndicatorHeight = 34;
|
|
86
|
+
const outerWidth = width + framePadding * 2;
|
|
87
|
+
const outerHeight = height + statusBarHeight + homeIndicatorHeight + framePadding * 2;
|
|
88
|
+
const contentX = framePadding;
|
|
89
|
+
const contentY = framePadding + statusBarHeight;
|
|
90
|
+
const before = `<rect width="${outerWidth}" height="${outerHeight}" rx="40" fill="#1a1a1a" />`
|
|
91
|
+
+ `<rect x="${framePadding}" y="${framePadding}" width="${width}" height="${outerHeight - framePadding * 2}" rx="4" fill="#0d1117" />`
|
|
92
|
+
+ `<rect x="${outerWidth / 2 - 60}" y="${framePadding}" width="120" height="28" rx="12" fill="#1a1a1a" />`
|
|
93
|
+
+ `<text x="${framePadding + 16}" y="${framePadding + 28}" font-family="-apple-system, sans-serif" font-size="12" font-weight="600" fill="#e6edf3">9:41</text>`
|
|
94
|
+
+ `<rect x="${outerWidth / 2 - 67}" y="${outerHeight - framePadding - 20}" width="134" height="5" rx="2.5" fill="#e6edf3" opacity="0.3" />`;
|
|
95
|
+
return { outerWidth, outerHeight, contentX, contentY, defs: "", before, after: "" };
|
|
96
|
+
}
|
|
97
|
+
function hostFrom(url) {
|
|
98
|
+
try {
|
|
99
|
+
return new URL(url).hostname;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return url;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function escapeXml(s) {
|
|
106
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
107
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Domotion CLI — DOM-to-animated-SVG renderer.
|
|
4
|
+
*
|
|
5
|
+
* Two commands:
|
|
6
|
+
* domotion capture <input> [options] single-frame capture
|
|
7
|
+
* domotion animate <config.json> multi-frame animated capture
|
|
8
|
+
*
|
|
9
|
+
* `<input>` for `capture` may be:
|
|
10
|
+
* - a URL (`https://...`, `http://...`)
|
|
11
|
+
* - a local HTML file path
|
|
12
|
+
* - `-` to read HTML from stdin
|
|
13
|
+
*
|
|
14
|
+
* Run `domotion --help` for the full option list.
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Domotion CLI — DOM-to-animated-SVG renderer.
|
|
4
|
+
*
|
|
5
|
+
* Two commands:
|
|
6
|
+
* domotion capture <input> [options] single-frame capture
|
|
7
|
+
* domotion animate <config.json> multi-frame animated capture
|
|
8
|
+
*
|
|
9
|
+
* `<input>` for `capture` may be:
|
|
10
|
+
* - a URL (`https://...`, `http://...`)
|
|
11
|
+
* - a local HTML file path
|
|
12
|
+
* - `-` to read HTML from stdin
|
|
13
|
+
*
|
|
14
|
+
* Run `domotion --help` for the full option list.
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
17
|
+
import { resolve, dirname, basename } from "node:path";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
import { parseArgs } from "node:util";
|
|
20
|
+
import { captureElementTree, elementTreeToSvg, wrapSvg, generateAnimatedSvg, optimizeSvg, launchChromium, logCaptureWarnings, discoverAndRegisterWebfonts, attachWebfontTracker, clearWebfonts, } from "./index.js";
|
|
21
|
+
const VERSION = "0.1.0";
|
|
22
|
+
const HELP = `domotion ${VERSION} — DOM-to-animated-SVG renderer
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
domotion capture <input> [options]
|
|
26
|
+
domotion animate <config.json>
|
|
27
|
+
domotion --help | --version
|
|
28
|
+
|
|
29
|
+
Commands:
|
|
30
|
+
capture Capture a single frame from a URL or HTML file as SVG.
|
|
31
|
+
animate Capture multiple frames described by a JSON config and stitch
|
|
32
|
+
them into one animated SVG with CSS keyframe transitions.
|
|
33
|
+
|
|
34
|
+
capture options:
|
|
35
|
+
-o, --output <path> Output SVG path (default: stdout, or <input>.svg
|
|
36
|
+
when input is a file).
|
|
37
|
+
--width <n> Viewport width in CSS pixels (default 800).
|
|
38
|
+
--height <n> Viewport height in CSS pixels (default 600).
|
|
39
|
+
--selector <css> Element selector to capture (default "body").
|
|
40
|
+
--clip <x,y,w,h> Capture only this region (default: full viewport).
|
|
41
|
+
--scroll <x,y> Scroll the page to this offset before capturing.
|
|
42
|
+
--wait <ms> Sleep this long after the page settles (default 200).
|
|
43
|
+
--wait-for <css> Wait for this selector to appear before capturing.
|
|
44
|
+
--no-fonts-ready Skip the document.fonts.ready wait (default: wait).
|
|
45
|
+
--optimize Run output through SVGO.
|
|
46
|
+
--warnings Log capture warnings to stderr after capture.
|
|
47
|
+
--mobile Emulate a mobile device (iOS UA, isMobile=true).
|
|
48
|
+
--color-scheme <s> Set prefers-color-scheme: "light" | "dark" | "no-preference".
|
|
49
|
+
|
|
50
|
+
animate config (JSON):
|
|
51
|
+
{
|
|
52
|
+
"width": 800,
|
|
53
|
+
"height": 400,
|
|
54
|
+
"output": "demo.svg",
|
|
55
|
+
"optimize": true,
|
|
56
|
+
"frames": [
|
|
57
|
+
{
|
|
58
|
+
"input": "./frames/start.html", // or a URL
|
|
59
|
+
"duration": 1500, // ms held on screen
|
|
60
|
+
"transition": { "type": "crossfade", "duration": 300 },
|
|
61
|
+
"selector": "body", // optional
|
|
62
|
+
"wait": 200, // optional ms
|
|
63
|
+
"waitFor": ".ready", // optional CSS selector
|
|
64
|
+
"scroll": [0, 0], // optional [x, y]
|
|
65
|
+
"actions": [ // optional, run before capture
|
|
66
|
+
{ "type": "click", "selector": ".btn" },
|
|
67
|
+
{ "type": "fill", "selector": "input", "value": "hi" },
|
|
68
|
+
{ "type": "press", "key": "Enter" },
|
|
69
|
+
{ "type": "scroll", "y": 200 },
|
|
70
|
+
{ "type": "hover", "selector": ".tooltip" },
|
|
71
|
+
{ "type": "wait", "ms": 300 }
|
|
72
|
+
],
|
|
73
|
+
"overlays": [ // see Overlay types
|
|
74
|
+
{ "kind": "tap", "x": 100, "y": 50 },
|
|
75
|
+
{ "kind": "typing", "text": "Hello", "x": 20, "y": 40 }
|
|
76
|
+
],
|
|
77
|
+
"animations": [ // intra-frame motion
|
|
78
|
+
{
|
|
79
|
+
"selector": ".bar", // CSS selector in source HTML
|
|
80
|
+
"property": "transform", // or width/height/opacity/translateX/translateY
|
|
81
|
+
"from": "scaleX(0)",
|
|
82
|
+
"to": "scaleX(1)",
|
|
83
|
+
"duration": 2000,
|
|
84
|
+
"easing": "ease-out", // optional, default "linear"
|
|
85
|
+
"delay": 150 // optional ms after frame start
|
|
86
|
+
}
|
|
87
|
+
]
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
Transition types: "crossfade" | "push-left" | "scroll" | "cut".
|
|
93
|
+
("cut" = instant; duration is ignored.)
|
|
94
|
+
Paths in "input" are resolved relative to the config file's directory.
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
# Capture the front page of example.com at 1280×720.
|
|
98
|
+
domotion capture https://example.com --width 1280 --height 720 -o demo.svg
|
|
99
|
+
|
|
100
|
+
# Capture a local HTML file, optimised, only the .hero region.
|
|
101
|
+
domotion capture ./hero.html --selector ".hero" --optimize -o hero.svg
|
|
102
|
+
|
|
103
|
+
# Capture HTML piped on stdin.
|
|
104
|
+
cat my.html | domotion capture - -o out.svg
|
|
105
|
+
|
|
106
|
+
# Build a 3-frame animated demo from a config.
|
|
107
|
+
domotion animate ./demo.json
|
|
108
|
+
`;
|
|
109
|
+
void main();
|
|
110
|
+
async function main() {
|
|
111
|
+
const argv = process.argv.slice(2);
|
|
112
|
+
if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
|
|
113
|
+
process.stdout.write(HELP);
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
if (argv[0] === "--version" || argv[0] === "-v") {
|
|
117
|
+
process.stdout.write(`${VERSION}\n`);
|
|
118
|
+
process.exit(0);
|
|
119
|
+
}
|
|
120
|
+
const cmd = argv[0];
|
|
121
|
+
const rest = argv.slice(1);
|
|
122
|
+
try {
|
|
123
|
+
if (cmd === "capture") {
|
|
124
|
+
await runCapture(rest);
|
|
125
|
+
}
|
|
126
|
+
else if (cmd === "animate") {
|
|
127
|
+
await runAnimate(rest);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
process.stderr.write(`domotion: unknown command "${cmd}"\n\n`);
|
|
131
|
+
process.stderr.write(HELP);
|
|
132
|
+
process.exit(2);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
process.stderr.write(`domotion: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function runCapture(args) {
|
|
141
|
+
const { values, positionals } = parseArgs({
|
|
142
|
+
args,
|
|
143
|
+
allowPositionals: true,
|
|
144
|
+
strict: true,
|
|
145
|
+
options: {
|
|
146
|
+
output: { type: "string", short: "o" },
|
|
147
|
+
width: { type: "string" },
|
|
148
|
+
height: { type: "string" },
|
|
149
|
+
selector: { type: "string" },
|
|
150
|
+
clip: { type: "string" },
|
|
151
|
+
scroll: { type: "string" },
|
|
152
|
+
wait: { type: "string" },
|
|
153
|
+
"wait-for": { type: "string" },
|
|
154
|
+
"no-fonts-ready": { type: "boolean" },
|
|
155
|
+
optimize: { type: "boolean" },
|
|
156
|
+
warnings: { type: "boolean" },
|
|
157
|
+
mobile: { type: "boolean" },
|
|
158
|
+
"color-scheme": { type: "string" },
|
|
159
|
+
help: { type: "boolean", short: "h" },
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
if (values.help === true) {
|
|
163
|
+
process.stdout.write(HELP);
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
if (positionals.length === 0)
|
|
167
|
+
throw new Error("capture: missing <input> (URL, path, or '-')");
|
|
168
|
+
if (positionals.length > 1)
|
|
169
|
+
throw new Error(`capture: unexpected extra argument "${positionals[1]}"`);
|
|
170
|
+
const input = positionals[0];
|
|
171
|
+
const flags = {
|
|
172
|
+
output: values.output,
|
|
173
|
+
width: parseIntFlag(values.width, "width", 800),
|
|
174
|
+
height: parseIntFlag(values.height, "height", 600),
|
|
175
|
+
selector: values.selector ?? "body",
|
|
176
|
+
clip: values.clip != null ? parseTuple(values.clip, 4, "clip") : undefined,
|
|
177
|
+
scroll: values.scroll != null ? parseTuple(values.scroll, 2, "scroll") : undefined,
|
|
178
|
+
wait: parseIntFlag(values.wait, "wait", 200),
|
|
179
|
+
waitFor: values["wait-for"],
|
|
180
|
+
fontsReady: values["no-fonts-ready"] !== true,
|
|
181
|
+
optimize: values.optimize === true,
|
|
182
|
+
warnings: values.warnings === true,
|
|
183
|
+
mobile: values.mobile === true,
|
|
184
|
+
colorScheme: parseColorScheme(values["color-scheme"]),
|
|
185
|
+
};
|
|
186
|
+
const browser = await launchChromium();
|
|
187
|
+
try {
|
|
188
|
+
const ctx = await browser.newContext({
|
|
189
|
+
viewport: { width: flags.width, height: flags.height },
|
|
190
|
+
isMobile: flags.mobile,
|
|
191
|
+
...(flags.mobile ? { userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)" } : {}),
|
|
192
|
+
...(flags.colorScheme != null ? { colorScheme: flags.colorScheme } : {}),
|
|
193
|
+
});
|
|
194
|
+
const page = await ctx.newPage();
|
|
195
|
+
// DM-479: bump Playwright's 30 s defaults to 90 s. Long captures on
|
|
196
|
+
// heavy pages routinely push past 30 s.
|
|
197
|
+
page.setDefaultTimeout(90_000);
|
|
198
|
+
page.setDefaultNavigationTimeout(90_000);
|
|
199
|
+
// Track every font URL the browser fetches during the page load. Most
|
|
200
|
+
// webfonts are cross-origin (Google Fonts, Adobe Fonts CDNs) and don't
|
|
201
|
+
// expose their resource-timing entries to JS, so this listener-based
|
|
202
|
+
// tracker is how `discoverAndRegisterWebfonts` learns about them.
|
|
203
|
+
const tracker = attachWebfontTracker(page);
|
|
204
|
+
await loadInputIntoPage(page, input);
|
|
205
|
+
await applyReadyWaits(page, flags);
|
|
206
|
+
// Webfont discovery: now that document.fonts.ready resolved, walk the
|
|
207
|
+
// page's @font-face rules, fetch the actual bytes via the browser's
|
|
208
|
+
// request stack, and register them with text-to-path so the renderer
|
|
209
|
+
// draws with the real webfont glyphs instead of a system substitute.
|
|
210
|
+
clearWebfonts();
|
|
211
|
+
await discoverAndRegisterWebfonts(page, tracker.urls);
|
|
212
|
+
tracker.detach();
|
|
213
|
+
const clip = flags.clip ?? [0, 0, flags.width, flags.height];
|
|
214
|
+
const tree = await captureElementTree(page, flags.selector, {
|
|
215
|
+
x: clip[0], y: clip[1], width: clip[2], height: clip[3],
|
|
216
|
+
});
|
|
217
|
+
const inner = elementTreeToSvg(tree, clip[2], clip[3]);
|
|
218
|
+
let svg = wrapSvg(inner, clip[2], clip[3]);
|
|
219
|
+
if (flags.optimize)
|
|
220
|
+
svg = optimizeSvg(svg);
|
|
221
|
+
if (flags.warnings)
|
|
222
|
+
logCaptureWarnings("capture");
|
|
223
|
+
const outPath = resolveOutputPath(flags.output, input, ".svg");
|
|
224
|
+
if (outPath === null) {
|
|
225
|
+
process.stdout.write(svg);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
writeFileSync(outPath, svg);
|
|
229
|
+
process.stderr.write(`Wrote ${outPath} (${(svg.length / 1024).toFixed(1)} KB)\n`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
await browser.close();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async function runAnimate(args) {
|
|
237
|
+
const { values, positionals } = parseArgs({
|
|
238
|
+
args,
|
|
239
|
+
allowPositionals: true,
|
|
240
|
+
strict: true,
|
|
241
|
+
options: {
|
|
242
|
+
output: { type: "string", short: "o" },
|
|
243
|
+
optimize: { type: "boolean" },
|
|
244
|
+
help: { type: "boolean", short: "h" },
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
if (values.help === true) {
|
|
248
|
+
process.stdout.write(HELP);
|
|
249
|
+
process.exit(0);
|
|
250
|
+
}
|
|
251
|
+
if (positionals.length === 0)
|
|
252
|
+
throw new Error("animate: missing <config.json>");
|
|
253
|
+
if (positionals.length > 1)
|
|
254
|
+
throw new Error(`animate: unexpected extra argument "${positionals[1]}"`);
|
|
255
|
+
const configPath = resolve(positionals[0]);
|
|
256
|
+
if (!existsSync(configPath))
|
|
257
|
+
throw new Error(`animate: config not found: ${configPath}`);
|
|
258
|
+
const cfg = JSON.parse(readFileSync(configPath, "utf8"));
|
|
259
|
+
validateAnimateConfig(cfg);
|
|
260
|
+
const configDir = dirname(configPath);
|
|
261
|
+
const browser = await launchChromium();
|
|
262
|
+
try {
|
|
263
|
+
const ctx = await browser.newContext({
|
|
264
|
+
viewport: { width: cfg.width, height: cfg.height },
|
|
265
|
+
isMobile: cfg.mobile === true,
|
|
266
|
+
...(cfg.mobile === true ? { userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)" } : {}),
|
|
267
|
+
...(cfg.colorScheme != null ? { colorScheme: cfg.colorScheme } : {}),
|
|
268
|
+
});
|
|
269
|
+
const page = await ctx.newPage();
|
|
270
|
+
// DM-479: 90 s instead of Playwright's 30 s default.
|
|
271
|
+
page.setDefaultTimeout(90_000);
|
|
272
|
+
page.setDefaultNavigationTimeout(90_000);
|
|
273
|
+
const frames = [];
|
|
274
|
+
// Frames may pull from different documents with different webfonts.
|
|
275
|
+
// Clear once at the start; each frame's discovery accumulates into the
|
|
276
|
+
// same registry. Multiple frames declaring the same family register
|
|
277
|
+
// multiple variants and the resolver picks the closest weight/style.
|
|
278
|
+
clearWebfonts();
|
|
279
|
+
// One tracker for the whole animate run — fonts fetched by any frame
|
|
280
|
+
// get accumulated, and we deduplicate URLs inside discoverAndRegister.
|
|
281
|
+
const tracker = attachWebfontTracker(page);
|
|
282
|
+
for (let i = 0; i < cfg.frames.length; i++) {
|
|
283
|
+
const fc = cfg.frames[i];
|
|
284
|
+
const input = resolveFrameInput(fc.input, configDir);
|
|
285
|
+
await loadInputIntoPage(page, input);
|
|
286
|
+
await applyReadyWaits(page, {
|
|
287
|
+
wait: fc.wait ?? 200,
|
|
288
|
+
waitFor: fc.waitFor,
|
|
289
|
+
fontsReady: true,
|
|
290
|
+
});
|
|
291
|
+
await discoverAndRegisterWebfonts(page, tracker.urls);
|
|
292
|
+
if (fc.scroll != null) {
|
|
293
|
+
const sx = fc.scroll[0], sy = fc.scroll[1];
|
|
294
|
+
await page.evaluate((coords) => window.scrollTo(coords[0], coords[1]), [sx, sy]);
|
|
295
|
+
}
|
|
296
|
+
if (fc.actions != null)
|
|
297
|
+
await runActions(page, fc.actions);
|
|
298
|
+
// Intra-frame animations (DM-209): tag the live DOM with
|
|
299
|
+
// `data-domotion-anim="<id>"` for each animation's selector. The capture
|
|
300
|
+
// pass picks up the data attribute and the renderer surfaces it as
|
|
301
|
+
// class="anim-<id>" on the rendered group, which the animator targets
|
|
302
|
+
// with a CSS keyframe block.
|
|
303
|
+
const resolvedAnimations = [];
|
|
304
|
+
if (fc.animations != null && fc.animations.length > 0) {
|
|
305
|
+
for (let ai = 0; ai < fc.animations.length; ai++) {
|
|
306
|
+
const a = fc.animations[ai];
|
|
307
|
+
const animId = `f${i}a${ai}`;
|
|
308
|
+
await page.evaluate((args) => {
|
|
309
|
+
const els = document.querySelectorAll(args.selector);
|
|
310
|
+
els.forEach((el) => {
|
|
311
|
+
if (el instanceof HTMLElement)
|
|
312
|
+
el.dataset.domotionAnim = args.animId;
|
|
313
|
+
});
|
|
314
|
+
}, { selector: a.selector, animId });
|
|
315
|
+
resolvedAnimations.push({
|
|
316
|
+
animId,
|
|
317
|
+
property: a.property,
|
|
318
|
+
from: a.from,
|
|
319
|
+
to: a.to,
|
|
320
|
+
duration: a.duration,
|
|
321
|
+
easing: a.easing,
|
|
322
|
+
delay: a.delay,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const tree = await captureElementTree(page, fc.selector ?? "body", {
|
|
327
|
+
x: 0, y: 0, width: cfg.width, height: cfg.height,
|
|
328
|
+
});
|
|
329
|
+
const svgContent = elementTreeToSvg(tree, cfg.width, cfg.height, `f${i}-`);
|
|
330
|
+
// Resolve SVG-kind overlays: read each `src` from disk, namespace its
|
|
331
|
+
// ids, and replace with `innerSvg`. Other overlay kinds pass through
|
|
332
|
+
// verbatim. (DM-210.)
|
|
333
|
+
const overlays = resolveSvgOverlays(fc.overlays, configDir, i);
|
|
334
|
+
frames.push({
|
|
335
|
+
svgContent,
|
|
336
|
+
duration: fc.duration,
|
|
337
|
+
transition: fc.transition,
|
|
338
|
+
overlays,
|
|
339
|
+
animations: resolvedAnimations.length > 0 ? resolvedAnimations : undefined,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
tracker.detach();
|
|
343
|
+
let svg = generateAnimatedSvg({ width: cfg.width, height: cfg.height, frames });
|
|
344
|
+
const optimize = values.optimize === true || cfg.optimize === true;
|
|
345
|
+
if (optimize)
|
|
346
|
+
svg = optimizeSvg(svg);
|
|
347
|
+
const outPath = resolveOutputPath(values.output ?? cfg.output, configPath, ".svg");
|
|
348
|
+
if (outPath === null) {
|
|
349
|
+
process.stdout.write(svg);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
writeFileSync(outPath, svg);
|
|
353
|
+
process.stderr.write(`Wrote ${outPath} (${(svg.length / 1024).toFixed(1)} KB, ${cfg.frames.length} frames)\n`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
await browser.close();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async function runActions(page, actions) {
|
|
361
|
+
for (const a of actions) {
|
|
362
|
+
if (a.type === "click")
|
|
363
|
+
await page.click(a.selector);
|
|
364
|
+
else if (a.type === "fill")
|
|
365
|
+
await page.fill(a.selector, a.value);
|
|
366
|
+
else if (a.type === "press")
|
|
367
|
+
await page.keyboard.press(a.key);
|
|
368
|
+
else if (a.type === "scroll")
|
|
369
|
+
await page.evaluate((coords) => window.scrollTo(coords[0], coords[1]), [a.x ?? 0, a.y ?? 0]);
|
|
370
|
+
else if (a.type === "hover")
|
|
371
|
+
await page.hover(a.selector);
|
|
372
|
+
else if (a.type === "wait")
|
|
373
|
+
await page.waitForTimeout(a.ms);
|
|
374
|
+
else
|
|
375
|
+
throw new Error(`animate: unknown action type "${a.type}"`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
async function loadInputIntoPage(page, input) {
|
|
379
|
+
if (input === "-") {
|
|
380
|
+
const html = readFileSync(0, "utf8"); // stdin
|
|
381
|
+
await page.setContent(html, { waitUntil: "domcontentloaded" });
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (/^https?:\/\//i.test(input)) {
|
|
385
|
+
await page.goto(input, { waitUntil: "networkidle" });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const path = resolve(input);
|
|
389
|
+
if (!existsSync(path))
|
|
390
|
+
throw new Error(`input file not found: ${path}`);
|
|
391
|
+
await page.goto(pathToFileURL(path).href, { waitUntil: "networkidle" });
|
|
392
|
+
}
|
|
393
|
+
async function applyReadyWaits(page, flags) {
|
|
394
|
+
if (flags.fontsReady) {
|
|
395
|
+
await page.evaluate(() => document.fonts.ready);
|
|
396
|
+
}
|
|
397
|
+
if (flags.waitFor != null) {
|
|
398
|
+
await page.waitForSelector(flags.waitFor, { state: "visible" });
|
|
399
|
+
}
|
|
400
|
+
if (flags.wait > 0) {
|
|
401
|
+
await page.waitForTimeout(flags.wait);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
function validateAnimateConfig(cfg) {
|
|
405
|
+
if (typeof cfg.width !== "number" || typeof cfg.height !== "number") {
|
|
406
|
+
throw new Error("animate: config requires numeric width and height");
|
|
407
|
+
}
|
|
408
|
+
if (!Array.isArray(cfg.frames) || cfg.frames.length === 0) {
|
|
409
|
+
throw new Error("animate: config.frames must be a non-empty array");
|
|
410
|
+
}
|
|
411
|
+
for (let i = 0; i < cfg.frames.length; i++) {
|
|
412
|
+
const f = cfg.frames[i];
|
|
413
|
+
if (typeof f.input !== "string")
|
|
414
|
+
throw new Error(`animate: frames[${i}].input must be a string`);
|
|
415
|
+
if (typeof f.duration !== "number")
|
|
416
|
+
throw new Error(`animate: frames[${i}].duration must be a number`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Walk a frame's overlay list, expand `kind: "svg"` entries by reading the
|
|
421
|
+
* referenced SVG file, namespacing its ids, and replacing `src` with the
|
|
422
|
+
* inlined `innerSvg`. Other overlay kinds pass through verbatim.
|
|
423
|
+
*/
|
|
424
|
+
function resolveSvgOverlays(rawOverlays, configDir, frameIdx) {
|
|
425
|
+
if (rawOverlays == null)
|
|
426
|
+
return undefined;
|
|
427
|
+
const out = [];
|
|
428
|
+
let svgIdx = 0;
|
|
429
|
+
for (const ov of rawOverlays) {
|
|
430
|
+
if (ov != null && typeof ov === "object" && ov.kind === "svg") {
|
|
431
|
+
const raw = ov;
|
|
432
|
+
const srcPath = resolve(configDir, raw.src);
|
|
433
|
+
if (!existsSync(srcPath))
|
|
434
|
+
throw new Error(`animate: svg overlay file not found: ${srcPath}`);
|
|
435
|
+
const fileText = readFileSync(srcPath, "utf8");
|
|
436
|
+
const animId = `s${svgIdx++}`;
|
|
437
|
+
const namespaced = namespaceSvgIds(fileText, `f${frameIdx}o${animId}-`);
|
|
438
|
+
out.push({
|
|
439
|
+
kind: "svg",
|
|
440
|
+
innerSvg: namespaced,
|
|
441
|
+
x: raw.x, y: raw.y, width: raw.width, height: raw.height,
|
|
442
|
+
animId,
|
|
443
|
+
enter: raw.enter, exit: raw.exit,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
out.push(ov);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Strip the outer `<svg>` wrapper (if present) from an SVG file's contents,
|
|
454
|
+
* then prefix every `id="..."`, `href="#..."`, and `xlink:href="#..."` with
|
|
455
|
+
* the given prefix so multiple inlined SVGs can coexist in one document
|
|
456
|
+
* without id collisions.
|
|
457
|
+
*/
|
|
458
|
+
function namespaceSvgIds(svg, prefix) {
|
|
459
|
+
// Strip XML decl + outer <svg ...> wrapper.
|
|
460
|
+
let inner = svg;
|
|
461
|
+
inner = inner.replace(/<\?xml[^>]*\?>/, "");
|
|
462
|
+
inner = inner.replace(/<svg\b[^>]*>/, "");
|
|
463
|
+
inner = inner.replace(/<\/svg>\s*$/, "");
|
|
464
|
+
// Prefix ids and hash references.
|
|
465
|
+
inner = inner.replace(/\bid="([^"]+)"/g, (_m, id) => `id="${prefix}${id}"`);
|
|
466
|
+
inner = inner.replace(/\b(href|xlink:href)="#([^"]+)"/g, (_m, attr, id) => `${attr}="#${prefix}${id}"`);
|
|
467
|
+
inner = inner.replace(/url\(#([^)]+)\)/g, (_m, id) => `url(#${prefix}${id})`);
|
|
468
|
+
return inner;
|
|
469
|
+
}
|
|
470
|
+
function resolveFrameInput(input, configDir) {
|
|
471
|
+
if (input === "-")
|
|
472
|
+
return input;
|
|
473
|
+
if (/^https?:\/\//i.test(input))
|
|
474
|
+
return input;
|
|
475
|
+
return resolve(configDir, input);
|
|
476
|
+
}
|
|
477
|
+
function parseIntFlag(value, name, def) {
|
|
478
|
+
if (value == null)
|
|
479
|
+
return def;
|
|
480
|
+
const n = Number(value);
|
|
481
|
+
if (!Number.isFinite(n) || Math.floor(n) !== n || n <= 0) {
|
|
482
|
+
throw new Error(`--${name} expects a positive integer, got "${value}"`);
|
|
483
|
+
}
|
|
484
|
+
return n;
|
|
485
|
+
}
|
|
486
|
+
function parseColorScheme(value) {
|
|
487
|
+
if (value == null)
|
|
488
|
+
return undefined;
|
|
489
|
+
if (value === "light" || value === "dark" || value === "no-preference")
|
|
490
|
+
return value;
|
|
491
|
+
throw new Error(`--color-scheme expects one of "light", "dark", "no-preference"; got "${value}"`);
|
|
492
|
+
}
|
|
493
|
+
function parseTuple(value, len, name) {
|
|
494
|
+
const parts = value.split(",").map((s) => s.trim());
|
|
495
|
+
if (parts.length !== len)
|
|
496
|
+
throw new Error(`--${name} expects ${len} comma-separated numbers, got "${value}"`);
|
|
497
|
+
const nums = parts.map((p) => Number(p));
|
|
498
|
+
if (nums.some((n) => !Number.isFinite(n)))
|
|
499
|
+
throw new Error(`--${name} contains a non-numeric component: "${value}"`);
|
|
500
|
+
return nums;
|
|
501
|
+
}
|
|
502
|
+
function resolveOutputPath(output, input, ext) {
|
|
503
|
+
if (output === "-")
|
|
504
|
+
return null;
|
|
505
|
+
if (output != null)
|
|
506
|
+
return resolve(output);
|
|
507
|
+
if (input === "-" || /^https?:\/\//i.test(input))
|
|
508
|
+
return null; // stream to stdout
|
|
509
|
+
// Local file → write next to it with the same basename.
|
|
510
|
+
const stem = basename(input).replace(/\.[^.]+$/, "");
|
|
511
|
+
return resolve(dirname(input), `${stem}${ext}`);
|
|
512
|
+
}
|